name: Create Release on: push: branches: - main # Automatically create release when merging to main tags: - 'v*.*.*' # Also support manual version tags workflow_dispatch: # Manual trigger for re-runs (no inputs needed) permissions: {} # Minimal permissions at workflow level for OSSF Scorecard # Prevent multiple full pipeline runs from competing for runners simultaneously. # cancel-in-progress: false ensures the active run completes; new runs queue (at most # 1 in-progress + 1 pending). Tag pushes get their own group (refs/tags/v1.2.3). concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false jobs: # ============================================================================ # VERSION CHECK - Skip expensive jobs when the release already exists # ============================================================================ # Lightweight pre-flight: reads __version__.py, checks if a GitHub release # for that tag already exists. Tag pushes and workflow_dispatch bypass the # check (always proceed). All gate jobs depend on should_release == 'true'. # ============================================================================ version-check: runs-on: ubuntu-latest outputs: should_release: ${{ steps.check.outputs.should_release }} permissions: contents: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Sparse checkout version file if: ${{ !(github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/'))) }} uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: sparse-checkout: src/local_deep_research/__version__.py sparse-checkout-cone-mode: false fetch-depth: 1 persist-credentials: false - name: Check if release is needed id: check env: EVENT_NAME: ${{ github.event_name }} GIT_REF: ${{ github.ref }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} run: | # Tag pushes and manual triggers always proceed if [[ "$EVENT_NAME" = "workflow_dispatch" ]] || [[ "$EVENT_NAME" = "push" && "$GIT_REF" =~ ^refs/tags/ ]]; then echo "should_release=true" >> "$GITHUB_OUTPUT" echo "Bypass: $EVENT_NAME on $GIT_REF — always proceed" exit 0 fi # For main branch pushes: check if version has an existing release VERSION=$(grep -oP '(?<=__version__ = ")[^"]*' src/local_deep_research/__version__.py) if [[ -z "$VERSION" ]]; then echo "ERROR: Could not extract version from __version__.py" >&2 exit 1 fi TAG="v${VERSION}" echo "Detected version: $VERSION (tag: $TAG)" if gh release view "$TAG" --repo "$GH_REPO" >/dev/null 2>&1; then echo "should_release=false" >> "$GITHUB_OUTPUT" echo "Release $TAG already exists — skipping scans" else echo "should_release=true" >> "$GITHUB_OUTPUT" echo "Release $TAG does not exist — proceeding with scans" fi # ============================================================================ # SECURITY GATE - All security scans must pass before release proceeds # ============================================================================ # This gate runs all security scans (17 checks): SAST, container/IaC, # dependency, secret detection, workflow security, runtime validation, # and backwards-compatibility. # The build job depends on this gate, ensuring no release can be created # without passing all security checks. # # NOTE: Docker publishing now runs inline (reusable workflow_call to # docker-publish.yml), and PyPI publishing is still dispatched via # repository_dispatch to publish.yml (PyPI Trusted Publishing does not # support reusable workflows — see pypa/gh-action-pypi-publish#166 and # pypi/warehouse#11096). Neither publish path is triggered by creating a # release via the GitHub UI — only this workflow can dispatch them — so # the security-gate flow is preserved. # ============================================================================ release-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' uses: ./.github/workflows/release-gate.yml permissions: contents: read security-events: write actions: read packages: read # needed by codeql-scan # ============================================================================ # CI GATE - All CI checks must pass before release proceeds # ============================================================================ # This gate runs all PR-quality checks (linting, type checking, tests, # validation) that complement the security-focused release-gate. # ============================================================================ ci-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' uses: ./.github/workflows/ci-gate.yml permissions: contents: write # Required: docker-tests deploys coverage to gh-pages (force push) pull-requests: write # Required: docker-tests posts coverage PR comments (no-op in release context) secrets: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} # ============================================================================ # TEST GATE (Advisory) - WebKit tests run but don't block releases # ============================================================================ # Cosmetic rendering differences in WebKit should not block releases. # This gate is advisory: failures are logged but the release proceeds. # ============================================================================ test-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' # Runs in parallel with release-gate (no dependency besides version-check) uses: ./.github/workflows/playwright-webkit-tests.yml permissions: contents: read checks: write # ============================================================================ # E2E TEST GATE (Required) - Puppeteer functional tests # ============================================================================ # Verifies UI search workflow via real API calls (OpenRouter + Serper). # Must pass before any release proceeds. # ============================================================================ e2e-test-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' # Runs in parallel with release-gate (no dependency besides version-check) uses: ./.github/workflows/puppeteer-e2e-tests.yml permissions: contents: read pull-requests: write # Required: puppeteer-e2e-tests posts test result comments (no-op in release context) issues: write # Required: puppeteer-e2e-tests removes labels on completion (no-op in release context) secrets: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} SERPER_API_KEY: ${{ secrets.SERPER_API_KEY }} # ============================================================================ # RESPONSIVE TEST GATE (Advisory) - Responsive UI tests across viewports # ============================================================================ # Tests mobile and desktop responsive layouts. Failures are logged but # don't block releases (cosmetic viewport differences). # ============================================================================ responsive-test-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' uses: ./.github/workflows/responsive-ui-tests-enhanced.yml permissions: contents: read # ============================================================================ # BACKWARDS COMPATIBILITY GATE (Required) - Database version compatibility # ============================================================================ # Verifies current code can open databases created by previous PyPI versions. # Must pass before any release proceeds. # ============================================================================ compat-test-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' # Runs in parallel with release-gate (no dependency besides version-check) uses: ./.github/workflows/backwards-compatibility.yml permissions: contents: read # ============================================================================ # COMPOSE INTEGRATION GATE (Required) - Bundled docker-compose smoke test # ============================================================================ # Brings up the bundled docker-compose.yml end-to-end and verifies the whole # stack (searxng, ollama, local-deep-research) reaches a healthy state. # Would have caught #3874 (broken cap_drop on SearXNG) before users hit it. # Only runs at release time — not on the daily release-gate cron — because # the failure modes are tied to actual compose / image changes that ride # along with releases. # ============================================================================ compose-integration-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' uses: ./.github/workflows/compose-integration-test.yml permissions: contents: read # ============================================================================ # VULTURE DEAD CODE GATE (Advisory) - Detects unreachable / unused code # ============================================================================ # Scans src/ for dead code using vulture. Results are visible in the # Actions tab but failures do NOT block the release. # ============================================================================ vulture-gate: needs: [version-check] if: needs.version-check.outputs.should_release == 'true' uses: ./.github/workflows/vulture-dead-code.yml permissions: contents: read build: needs: [version-check, release-gate, ci-gate, test-gate, e2e-test-gate, responsive-test-gate, compat-test-gate, compose-integration-gate, vulture-gate] # test-gate (Playwright WebKit), responsive-test-gate, and vulture-gate are advisory # release-gate, ci-gate, e2e-test-gate, compat-test-gate, and compose-integration-gate are required if: ${{ !cancelled() && needs.release-gate.result == 'success' && needs.ci-gate.result == 'success' && needs.e2e-test-gate.result == 'success' && needs.compat-test-gate.result == 'success' && needs.compose-integration-gate.result == 'success' }} runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} short_sha: ${{ steps.version.outputs.short_sha }} release_exists: ${{ steps.check_release.outputs.exists }} hashes: ${{ steps.hash.outputs.hashes }} permissions: contents: read pull-requests: read id-token: write # Required for cosign keyless signing with GitHub OIDC steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: persist-credentials: false fetch-depth: 0 - name: Verify build commit is on main # `push: branches: main` is trivially on main, but `push: tags: v*` # and `workflow_dispatch` can fire from any ref — a tag pointing at # a feature-branch commit, or a manual dispatch from a stale branch, # would otherwise sail through every gate. `fetch-depth: 0` above # makes origin/main reachable for the merge-base check. run: | set -euo pipefail if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then echo "::error::Build commit $GITHUB_SHA is not an ancestor of origin/main — refusing to release" exit 1 fi echo "Build commit verified on main: $GITHUB_SHA" - name: Determine version id: version env: EVENT_NAME: ${{ github.event_name }} GIT_REF: ${{ github.ref }} run: | if [[ "$EVENT_NAME" = "push" && "$GIT_REF" =~ ^refs/tags/ ]]; then # Extract version from tag (remove 'v' prefix) VERSION="${GITHUB_REF#refs/tags/v}" else # Get version from __version__.py (main branch push or manual trigger) VERSION=$(grep -oP '(?<=__version__ = ")[^"]*' src/local_deep_research/__version__.py) fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" SHORT_SHA="${GITHUB_SHA:0:7}" echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - name: Check if release already exists id: check_release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION_TAG: ${{ steps.version.outputs.tag }} run: | if gh release view "$VERSION_TAG" >/dev/null 2>&1; then echo "Release $VERSION_TAG already exists, skipping..." echo "exists=true" >> "$GITHUB_OUTPUT" else echo "Release $VERSION_TAG does not exist, proceeding..." echo "exists=false" >> "$GITHUB_OUTPUT" fi - name: Verify version matches __version__.py if: steps.check_release.outputs.exists == 'false' env: EXPECTED_VERSION: ${{ steps.version.outputs.version }} run: | VERSION_FILE_VERSION=$(grep -oP '(?<=__version__ = ")[^"]*' src/local_deep_research/__version__.py) if [ "$VERSION_FILE_VERSION" != "$EXPECTED_VERSION" ]; then echo "Error: Version mismatch!" echo "Tag/input version: $EXPECTED_VERSION" echo "__version__.py version: $VERSION_FILE_VERSION" exit 1 fi echo "Version verified: $EXPECTED_VERSION" # SBOM generation uses the SHA-pinned trivy-action with an explicit # binary version pin. Previously this step shelled out # `apt-get install -y trivy` from the (unpinned) Aqua apt repo, which # in a job carrying `id-token: write` (line 229) meant a compromised # apt mirror could exfiltrate the OIDC request token AND tamper with # the SBOM bytes that the next step (Sign release artifacts) signs # under this repo's Sigstore identity — i.e., a fraudulent SBOM # carrying a legitimate cosign cert. Pin the action by SHA + the # binary by version tag so the toolchain is reproducible and matches # docker-publish.yml / prerelease-docker.yml. - name: Generate SBOM (SPDX JSON) if: steps.check_release.outputs.exists == 'false' uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: 'fs' scan-ref: '.' format: 'spdx-json' output: 'sbom-spdx.json' version: 'v0.69.2' - name: Generate SBOM (CycloneDX) if: steps.check_release.outputs.exists == 'false' uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: scan-type: 'fs' scan-ref: '.' format: 'cyclonedx' output: 'sbom-cyclonedx.json' version: 'v0.69.2' - name: List SBOMs if: steps.check_release.outputs.exists == 'false' run: | echo "SBOM files generated:" ls -lh sbom-*.json - name: Install Cosign if: steps.check_release.outputs.exists == 'false' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 with: # Pin to cosign v2.x — v4.1.2 of the installer ships cosign v3.0.6 # by default, but v3 enables --new-bundle-format ON BY DEFAULT. # That changes the signature/attestation on-wire format and would # require all downstream verifiers (including end-users running # the README cosign-verify recipe) to also be on v3+. Pin to # v2.6.3 (latest v2.x, includes the GHSA-w6c6-c85g-mmv6 patch) # for the legacy tag-based sigstore format until we explicitly # migrate to v3. cosign-release: 'v2.6.3' - name: Sign release artifacts with Sigstore if: steps.check_release.outputs.exists == 'false' run: | echo "=== Signing release artifacts with Sigstore ===" # Sign SBOM files using keyless signing (OIDC). # `--bundle` writes a Sigstore protobuf bundle containing the # signature, certificate, and Rekor inclusion proof in one file. # Supported in cosign v2.4.0+ (we're pinned to v2.6.3 above). cosign sign-blob --yes --bundle sbom-spdx.json.bundle sbom-spdx.json cosign sign-blob --yes --bundle sbom-cyclonedx.json.bundle sbom-cyclonedx.json echo "Signed SBOM files with Sigstore" echo "" echo "Bundle files generated (contain signature + certificate):" ls -lh sbom-*.bundle - name: Verify SBOM bundles and sanity-check SBOM contents if: steps.check_release.outputs.exists == 'false' env: GH_REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} run: | set -euo pipefail # Round-trip: prove the README cosign-verify-blob recipe still # works for end users. cosign signs above but never verifies, so a # sigstore/cert-chain regression would silently break downstream # verification — caught only when a user complains. echo "=== Verifying SBOM bundles round-trip ===" for bundle in sbom-spdx.json.bundle sbom-cyclonedx.json.bundle; do json="${bundle%.bundle}" cosign verify-blob \ --bundle "$bundle" \ --certificate-identity-regexp="^https://github.com/${GH_REPO}/" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ "$json" done echo "Both SBOM bundles verify cleanly" # Trivy can emit a near-empty SBOM on partial scan failures # without surfacing a non-zero exit. Set a per-format floor at # 80% of the previous stable release's count; fall back to a hard # floor of 50 when no prior SBOM is fetchable (first release, GH # outage, releases predating SBOM attachment). SPDX and CycloneDX # counts differ (virtual packages, metadata-only entries) so each # format gets its own floor derived from its own prior count. # A cap of 2000 prevents runaway drift if a prior SBOM was # anomalously inflated by a Trivy bug. echo "" echo "=== Sanity-checking SBOM package counts ===" SPDX_COUNT=$(jq '.packages | length' sbom-spdx.json) CYCDX_COUNT=$(jq '.components | length' sbom-cyclonedx.json) echo "Current: SPDX=${SPDX_COUNT}, CycloneDX=${CYCDX_COUNT}" HARD_FLOOR=50 FLOOR_CAP=2000 SPDX_FLOOR="$HARD_FLOOR" CYCDX_FLOOR="$HARD_FLOOR" PREV_TAG=$(gh release list --repo "$GH_REPO" --limit 5 \ --json tagName,isPrerelease,isDraft \ --jq '[.[] | select(.isPrerelease==false and .isDraft==false)][0].tagName // empty') if [[ -n "$PREV_TAG" ]]; then PREV_DIR=/tmp/prev-sbom rm -rf "$PREV_DIR" && mkdir -p "$PREV_DIR" if gh release download "$PREV_TAG" --repo "$GH_REPO" \ --pattern 'sbom-spdx.json' --pattern 'sbom-cyclonedx.json' \ --dir "$PREV_DIR" 2>/dev/null; then if [[ -f "$PREV_DIR/sbom-spdx.json" ]]; then PREV_SPDX=$(jq '.packages | length' "$PREV_DIR/sbom-spdx.json") DYN=$(( PREV_SPDX * 80 / 100 )) DYN=$(( DYN < FLOOR_CAP ? DYN : FLOOR_CAP )) [[ "$DYN" -gt "$HARD_FLOOR" ]] && SPDX_FLOOR="$DYN" echo "Prior SPDX: $PREV_SPDX -> floor: $SPDX_FLOOR" fi if [[ -f "$PREV_DIR/sbom-cyclonedx.json" ]]; then PREV_CYCDX=$(jq '.components | length' "$PREV_DIR/sbom-cyclonedx.json") DYN=$(( PREV_CYCDX * 80 / 100 )) DYN=$(( DYN < FLOOR_CAP ? DYN : FLOOR_CAP )) [[ "$DYN" -gt "$HARD_FLOOR" ]] && CYCDX_FLOOR="$DYN" echo "Prior CycloneDX: $PREV_CYCDX -> floor: $CYCDX_FLOOR" fi else echo "No SBOMs in $PREV_TAG (older releases predate SBOM attachment) -> hard floor $HARD_FLOOR" fi else echo "No previous stable release found -> hard floor $HARD_FLOOR" fi if [[ "$SPDX_COUNT" -lt "$SPDX_FLOOR" ]]; then echo "::error::SPDX SBOM appears truncated ($SPDX_COUNT < floor $SPDX_FLOOR). Trivy may have failed silently." exit 1 fi if [[ "$CYCDX_COUNT" -lt "$CYCDX_FLOOR" ]]; then echo "::error::CycloneDX SBOM appears truncated ($CYCDX_COUNT < floor $CYCDX_FLOOR). Trivy may have failed silently." exit 1 fi echo "SBOM counts above per-format floors (SPDX≥$SPDX_FLOOR, CycloneDX≥$CYCDX_FLOOR)" - name: Generate hashes for SLSA provenance if: steps.check_release.outputs.exists == 'false' id: hash run: | # Generate SHA256 hashes for all release artifacts # Format: base64(sha256hash filename\nsha256hash filename\n...) echo "Generating hashes for SLSA provenance..." HASHES=$(sha256sum sbom-spdx.json sbom-spdx.json.bundle sbom-cyclonedx.json sbom-cyclonedx.json.bundle | base64 -w0) echo "hashes=$HASHES" >> "$GITHUB_OUTPUT" echo "Hashes generated for $(echo "$HASHES" | base64 -d | wc -l) files" - name: Upload artifacts for release if: steps.check_release.outputs.exists == 'false' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-artifacts path: | sbom-spdx.json sbom-spdx.json.bundle sbom-cyclonedx.json sbom-cyclonedx.json.bundle # 7 days, NOT 1: this job runs BEFORE the `release` environment # approval, and create-release downloads the artifact AFTER it. # The approval is human-paced and unbounded — with retention 1 # the v1.9.0 artifact expired 24h after build while the approval # came ~30h later, failing create-release with "Artifact not # found" and leaving the release unrecoverable (the signed SBOM # bytes referenced by the SLSA provenance subjects were gone). # # NOTE: the EFFECTIVE window for approving (or re-running a # failed) create-release is 5 days, not 7 — the provenance job # (also pre-approval) uploads provenance.intoto.jsonl inside the # SLSA generator reusable workflow, which hardcodes # retention-days: 5 with no caller input to change it (verified # at the pinned v2.1.0 commit). 7 here just keeps THIS artifact # from ever being the binding constraint; if the generator gains # a retention input, align both values. retention-days: 7 # Generate SLSA provenance for release artifacts provenance: needs: [build] # !cancelled() overrides the implicit success() which fails when advisory test-gate jobs fail if: ${{ !cancelled() && needs.build.result == 'success' && needs.build.outputs.release_exists == 'false' }} permissions: actions: read id-token: write contents: write uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a # v2.1.0 with: base64-subjects: "${{ needs.build.outputs.hashes }}" upload-assets: false # We'll upload manually with the release provenance-name: "provenance.intoto.jsonl" compile-generator: true # Build from source to bypass TUF key validation issues # Build, sign, and push the canonical Docker image. This is the single # build for the release — publish-docker (the reusable workflow_call # to docker-publish.yml) later retags this manifest by digest. The jobs # inside this reusable workflow declare `environment: release` so they # can read the env-scoped DOCKER_USERNAME / DOCKER_PASSWORD; the FIRST # (and only) `release` env approval click in this run unlocks ALL # release-env jobs together: prerelease-docker, publish-docker, # trigger-pypi, monitor-pypi, and create-release. After the # atomicity refactor, docker-publish is inline (reusable workflow_call) # so it does not require a separate second approval — its result is # visible to downstream jobs (create-release, cleanup-on-rejection) in # the same run. (Earlier iterations of the build-once-promote refactor # tried to run the canonical build pre-approval to give a real # test-then-approve window; that required repo-level Docker Hub # secrets, which we deliberately don't have.) The manifest_digest # output flows through to the promote step so digest preservation can # be verified end-to-end. prerelease-docker: needs: [build, provenance] if: ${{ !cancelled() && needs.build.result == 'success' && needs.provenance.result == 'success' && needs.build.outputs.release_exists == 'false' }} uses: ./.github/workflows/prerelease-docker.yml with: version: ${{ needs.build.outputs.version }} short_sha: ${{ needs.build.outputs.short_sha }} secrets: # Explicit list (not `secrets: inherit`) — defense-in-depth so a # future edit to prerelease-docker.yml that references an unrelated # secret would fail loudly rather than silently accessing inherited # values like PAT_TOKEN, OPENROUTER_API_KEY, or SERPER_API_KEY. DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} permissions: # Caller permissions cap what the called workflow can request. # Must include id-token: write so cosign keyless OIDC works on the # called workflow's create-manifest job. # NOTE: no `packages: write` — Docker Hub auth uses DOCKER_PASSWORD # secret, not GITHUB_TOKEN. `packages: write` only matters for ghcr.io. contents: read id-token: write # Retag the prerelease manifest as the release tags (:VERSION, :MAJOR_MINOR, # :latest). Runs as a reusable workflow_call so its result is visible to # downstream jobs in this run — specifically: create-release blocks on # success here, and cleanup-on-rejection keys its cosign-deletion logic on # `needs.publish-docker.result`. Before this refactor, docker-publish.yml # was triggered via repository_dispatch and its outcome was invisible to # the parent release.yml run, which made cosign artifact cleanup unsafe # after a partial retag failure (release tags could exist sharing the # prerelease manifest digest, and deleting `sha256-.{sig,att}` # would invalidate those release-tag signatures). publish-docker: needs: [build, prerelease-docker] if: ${{ !cancelled() && needs.prerelease-docker.result == 'success' && needs.build.outputs.release_exists == 'false' }} uses: ./.github/workflows/docker-publish.yml with: tag: ${{ needs.build.outputs.tag }} source_tag: prerelease-v${{ needs.build.outputs.version }}-${{ needs.build.outputs.short_sha }} expected_digest: ${{ needs.prerelease-docker.outputs.manifest_digest }} secrets: # Explicit secret pass — env-scoped (`release`) DOCKER_USERNAME and # DOCKER_PASSWORD become available to the callee's `promote` job # because that job declares `environment: release`. DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} permissions: # cosign VERIFY (the only sigstore operation in the callee) is a # read-only check against the public Rekor/Fulcio infrastructure — # no GitHub OIDC token is minted, so id-token: write is unnecessary. # OIDC is only needed for cosign SIGN, which happens upstream in # prerelease-docker.yml. contents: read create-release: # create-release waits for prerelease-docker (canonical image exists, # signed and attested), publish-docker (release tags retagged with # digest preserved, cosign transitivity verified), and monitor-pypi # (PyPI publish has completed). Only then is the GitHub Release # published — so the Release page never points at non-existent # artifacts. Gated by the `release` environment. needs: [build, provenance, prerelease-docker, publish-docker, monitor-pypi] if: ${{ !cancelled() && needs.build.result == 'success' && needs.provenance.result == 'success' && needs.prerelease-docker.result == 'success' && needs.publish-docker.result == 'success' && needs.monitor-pypi.result == 'success' && needs.build.outputs.release_exists == 'false' }} runs-on: ubuntu-latest environment: release permissions: contents: write pull-requests: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Sparse checkout release sources # changelog.d/ + pyproject.toml are needed by the "Render changelog" # step below (towncrier reads its [tool.towncrier] config from # pyproject and consumes fragments from changelog.d/). src/ is # deliberately not checked out: the [tool.towncrier] `name` field # in pyproject.toml lets towncrier skip its package-import path, # and --version on the CLI bypasses package-based version lookup. uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: sparse-checkout: | docs/release_notes/ changelog.d/ pyproject.toml sparse-checkout-cone-mode: false fetch-depth: 1 persist-credentials: false - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' - name: Install towncrier run: pip install towncrier==24.8.0 - name: Render changelog from fragments # Produces docs/release_notes/${RELEASE_VERSION}.md from changelog.d/ # fragments using the towncrier config in pyproject.toml. Towncrier # errors on an empty fragment directory, so guard with a glob check — # a maintenance release with no fragments should not fail the job. # README.md is excluded because changelog.d/README.md is the # contributor guide, not a fragment. # Fragments are consumed (deleted) locally on the runner; the # cleanup-changelog job re-runs this and opens a PR to persist the # changes back to main. env: RELEASE_VERSION: ${{ needs.build.outputs.version }} run: | set -euo pipefail fragment_count=$(find changelog.d -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l) if [[ "$fragment_count" -gt 0 ]]; then towncrier build --yes --version "$RELEASE_VERSION" echo "Rendered docs/release_notes/${RELEASE_VERSION}.md from ${fragment_count} fragment(s)" else echo "No changelog fragments found — skipping towncrier build" fi - name: Download build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-artifacts - name: Download SLSA provenance uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: provenance.intoto.jsonl - name: Download container-image SBOMs # Produced by prerelease-docker.yml — one SBOM per architecture # (sbom-amd64.spdx.json, sbom-arm64.spdx.json) from Syft. The # cosign attestations on each per-arch digest are the # cryptographically authoritative copies; attaching the raw JSON # to the GitHub Release makes them discoverable to humans # browsing the release page. uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: sbom path: container-sbom - name: Stage container-image SBOMs for release attachment # Rename per-arch SBOMs to avoid collision with the filesystem SBOM # `sbom-spdx.json` produced by the build job (Trivy scan of source # tree). Container SBOMs are the Syft scan of built image layers # per platform — different content. run: | set -euo pipefail # Per-arch SBOMs: rename sbom-amd64.spdx.json → sbom-container-amd64.spdx.json # (and similarly for arm64). prerelease-docker.yml no longer # produces a manifest-level sbom.spdx.json. for f in container-sbom/sbom-*.spdx.json; do [ -f "$f" ] || continue base=$(basename "$f") mv "$f" "sbom-container-${base#sbom-}" done ls -la sbom-container*.spdx.json || echo "No container SBOMs found" - name: List artifacts run: | echo "Release artifacts:" ls -la - name: Create GitHub Release run: | # Compose the release body before creation so the published body is # final on first publish (no edit-after window where downstream # workflows could see partial notes). # # Body layout (each section is omitted if its source is empty): # 1. AI-generated narrative (model = vars.AI_MODEL). Fed the # hand-written notes, the auto PR list, every PR's title+body, # and the actual diff vs the previous release. # 2. Hand-written narrative — docs/release_notes/.md, # rendered earlier in this job by towncrier from # changelog.d/ fragments. # 3. Horizontal rule # 4. Auto-generated, label-categorized PR list (already starts # with "## What's Changed", emitted by GitHub's generate-notes # API driven by .github/release.yml) # # The AI step is best-effort: if OPENROUTER_API_KEY is unset or the # call fails, we fall through silently with no AI section. Releases # must never fail because of an LLM hiccup. set -euo pipefail # Defense-in-depth: RELEASE_VERSION is interpolated into a regex # below, so reject anything that isn't a bare semver string. The # build job's "Determine version" step strips `refs/tags/v` for # tag pushes and reads __version__.py (bare "1.6.7") otherwise, # so a leading `v` here would mean an upstream contract change # and should fail loudly. if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][a-zA-Z0-9.+_-]+)?$ ]]; then echo "ERROR: RELEASE_VERSION '$RELEASE_VERSION' is not a valid bare semver string" >&2 exit 1 fi RELEASE_NOTES_FILE="docs/release_notes/${RELEASE_VERSION}.md" NOTES_FILE="$(mktemp)" PROMPT_FILE="$(mktemp)" AI_RESPONSE_FILE="$(mktemp)" AI_SUMMARY_FILE="$(mktemp)" PR_BODIES_FILE="$(mktemp)" DIFF_FILE="$(mktemp)" # ${DIFF_FILE}.clean is the staging path for the NUL-strip step; # rm -f tolerates the case where it doesn't exist. trap 'rm -f "$NOTES_FILE" "$PROMPT_FILE" "$AI_RESPONSE_FILE" "$AI_SUMMARY_FILE" "$PR_BODIES_FILE" "$DIFF_FILE" "${DIFF_FILE}.clean"' EXIT # `set -e` does NOT exit on a failing command substitution in an # assignment, so wrap with explicit failure handling. # target_commitish: for a tag that does not exist yet (the normal # case), generate-notes otherwise resolves the commit range # against default-branch HEAD at call time — after a delayed # approval the auto PR list would include PRs merged AFTER the # gated commit (the notes-side sibling of the dispatch-time-HEAD # drift fixed in this change). Pinning it keeps the PR list # consistent with the wheel/image/tag. Ignored when the tag # already exists. if ! AUTO_BODY=$(gh api -X POST \ "/repos/${GITHUB_REPOSITORY}/releases/generate-notes" \ -f tag_name="${RELEASE_TAG}" \ -f target_commitish="${GITHUB_SHA}" \ -q .body); then echo "ERROR: gh api generate-notes failed for $RELEASE_TAG" >&2 exit 1 fi # The hand-written narrative is the per-version file produced by # the "Render changelog from fragments" step above. Empty if there # were no fragments in changelog.d/ for this release (towncrier is # skipped in that case) — release then proceeds with auto-notes # plus whatever the AI produces from PR bodies + diff. HAND_NOTES="" if [[ -f "$RELEASE_NOTES_FILE" ]]; then HAND_NOTES=$(cat "$RELEASE_NOTES_FILE") else echo "WARNING: $RELEASE_NOTES_FILE not found — release proceeds without hand-written notes" fi # ---- PR descriptions (batch via GraphQL, one round-trip) ---- # Extract PR numbers from the auto-body's pull/ URLs. Dedupe # in case the same PR is linked twice. GraphQL aliases let us fetch # all PR titles + bodies in a single request, sidestepping the # rate-limit cost of N sequential REST calls. PR_NUMBERS=$(printf '%s' "$AUTO_BODY" | grep -oE 'pull/[0-9]+' | grep -oE '[0-9]+' | sort -u || true) : > "$PR_BODIES_FILE" if [[ -n "$PR_NUMBERS" ]]; then # GraphQL has a node/complexity ceiling. One aliased # pullRequest(number:) lookup per PR with three shallow fields # is cheap, but unbounded fan-out can still trip the limit on a # huge release. Cap at 200 PRs per query — sized for LDR # releases that bundle dependency-bump traffic (v1.6.10 had # 144 unique PRs). The 750k char overall prompt cap protects # against the per-PR body fan-out blowing the context window. pr_count=$(printf '%s\n' "$PR_NUMBERS" | grep -c . || true) if [[ "$pr_count" -gt 200 ]]; then echo "WARNING: $pr_count PRs in release — capping to first 200 for GraphQL fetch" PR_NUMBERS=$(printf '%s\n' "$PR_NUMBERS" | head -200) fi OWNER="${GITHUB_REPOSITORY%/*}" REPO="${GITHUB_REPOSITORY#*/}" QUERY="query { repository(owner: \"$OWNER\", name: \"$REPO\") {" i=0 while IFS= read -r num; do [[ -z "$num" ]] && continue QUERY+=" pr${i}: pullRequest(number: ${num}) { number title body }" i=$((i + 1)) done <<< "$PR_NUMBERS" QUERY+=" } }" if gh api graphql -f query="$QUERY" \ --jq '.data.repository | to_entries[] | .value | select(. != null) | "### PR #\(.number): \(.title)\n\((.body // "") | .[0:2000])\n"' \ > "$PR_BODIES_FILE" 2>/dev/null; then echo "Fetched $(printf '%s\n' "$PR_NUMBERS" | wc -l) PR descriptions ($(wc -c < "$PR_BODIES_FILE") chars)" else echo "WARNING: GraphQL PR fetch failed — proceeding without PR descriptions" : > "$PR_BODIES_FILE" fi fi # ---- Diff between previous release and current ---- # /releases/latest excludes drafts and prereleases. Returns 404 if # there is no prior stable release — the AI then runs without a # diff input. The diff is produced LOCALLY by git (shallow-fetch # of the previous tag), not by the compare API: the API silently # caps .files[] at 300 entries (the v1.8.1→v1.9.0 compare hit # exactly 300) and lists files alphabetically, so tail-truncation # dropped patches arbitrarily instead of by importance. The local # diff is complete, starts with a full --stat, and orders patch # bodies by signal (src/ first, largest change first) so the # 700,000-char budget (~175k tokens, headroom in Kimi K2 # Thinking's 262k context for prompt + 64k output) trims the # least informative patches first. Same noise filter as before # (lockfiles, generated docs, SBOM, static assets, snapshots, # binaries). : > "$DIFF_FILE" PREV_TAG="" if PREV_TAG=$(gh api "/repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null) && [[ -n "$PREV_TAG" && "$PREV_TAG" != "null" ]]; then # On a workflow re-run after the release already exists, # /releases/latest returns the just-created tag. Fall back to # the second-most-recent stable release so the diff is # meaningful instead of empty. if [[ "$PREV_TAG" == "$RELEASE_TAG" ]]; then echo "NOTE: /releases/latest is the current tag (re-run scenario) — looking up previous" # Pass RELEASE_TAG as a jq variable rather than splicing it # into the program text. RELEASE_TAG is already validated as # bare semver above, but --arg keeps shell quoting and jq # quoting fully separated regardless. RELEASES_JSON=$(gh api "/repos/${GITHUB_REPOSITORY}/releases?per_page=10" 2>/dev/null || printf '[]') PREV_TAG=$(printf '%s' "$RELEASES_JSON" | jq -r --arg rel "$RELEASE_TAG" \ '[.[] | select(.draft == false and .prerelease == false and .tag_name != $rel)][0].tag_name // empty' \ 2>/dev/null || true) if [[ -z "$PREV_TAG" ]]; then echo "WARNING: could not find a prior stable release — skipping diff" fi fi fi if [[ -n "$PREV_TAG" ]]; then echo "Fetching previous release tag $PREV_TAG for local diff" # Shallow-fetch just the previous tag's commit. NOTE: # actions/checkout configures a blob:none partial clone whenever # sparse-checkout is set, and this fetch inherits that filter — # the object store has the TREES of both commits but not the # blobs. The diffs below still work because git lazily # batch-fetches missing blobs from the promisor remote over # anonymous HTTPS (public repo; persist-credentials: false is # irrelevant to an unauthenticated fetch). If that lazy fetch # ever breaks (repo made private, anonymous throttling), the # python builder's git call exits non-zero and the guarded # `if !` below degrades to WARNING + empty diff — the release # itself is never blocked by this step. if git fetch --quiet --depth=1 origin "refs/tags/${PREV_TAG}:refs/tags/${PREV_TAG}"; then # Best-effort like every other AI input: a diff failure must # not fail the release, only shrink the prompt. if ! python3 - "$PREV_TAG" "$GITHUB_SHA" "$DIFF_FILE" 700000 <<'PYEOF' import re import subprocess import sys prev, head, out_path, budget = ( sys.argv[1], sys.argv[2], sys.argv[3], int(sys.argv[4]), ) # Same noise filter the old compare-API jq expression used. NOISE = re.compile( r"(package-lock\.json$|pdm\.lock$|\.min\.(js|css)$" r"|^docs/CONFIGURATION\.md$|^docs/ci/workflow-status\.md$" r"|^sbom-|/__pycache__/|\.pyc$|^docs/images/" r"|^src/local_deep_research/web/static/" r"|/(golden_master|snapshot)|\.snap$)" ) def git(*args): # errors="replace" keeps undecodable bytes from raising; # --no-renames keeps numstat paths plain (no "{a => b}" # munging) so the per-file diff below can address them. return subprocess.run( ["git", "--no-pager", *args], check=True, capture_output=True, text=True, errors="replace", ).stdout rng = f"{prev}..{head}" # The complete --stat goes first and is never truncated: even # when patch bodies below are dropped for budget, the model # always sees the full shape of the release. stat = git("diff", "--no-renames", "--stat=200", rng) # -z terminates entries with NUL and, crucially, disables # core.quotePath C-quoting: without it a path with non-ASCII or # special characters comes back as a quoted literal ("a\303\251") # that the per-file pathspec below silently fails to match — # the file would be counted as included while its patch is empty. files = [] for entry in git("diff", "--no-renames", "--numstat", "-z", rng).split("\0"): if not entry: continue adds, dels, path = entry.split("\t", 2) if adds == "-" or NOISE.search(path): # binary or noise continue files.append((path, int(adds) + int(dels))) # Signal order: source first, then everything else, then # tests/CI/docs; biggest change first within each group. Budget # overflow then drops the LEAST informative patches instead of # whatever sorted last alphabetically. def group(path): if path.startswith("src/"): return 0 if path.startswith(("tests/", ".github/", "docs/")): return 2 return 1 files.sort(key=lambda f: (group(f[0]), -f[1])) chunks = [f"===== DIFFSTAT ({prev}...{head}, complete) =====\n{stat}\n"] written = len(chunks[0]) dropped = [] unmatched = [] for path, _ in files: # :(literal) keeps glob metacharacters in a filename ([ * ?) # from being interpreted as pathspec wildcards. patch = git("diff", "--no-renames", rng, "--", f":(literal){path}") if not patch: # A pathspec that matches nothing exits 0 with empty # output — e.g. a filename whose bytes don't round-trip # the errors="replace" UTF-8 decode (U+FFFD in the # decoded path no longer equals the on-disk bytes). # Without this branch the file would be COUNTED as # included while its patch is silently absent. unmatched.append(path) continue if written + len(patch) > budget: dropped.append(path) continue chunks.append(patch) written += len(patch) if dropped: shown = ", ".join(dropped[:20]) + (" ..." if len(dropped) > 20 else "") chunks.append( f"\n[... {len(dropped)} lower-priority file diffs omitted to fit " f"the {budget}-char budget: {shown} ...]\n" ) if unmatched: shown = ", ".join(unmatched[:10]) + (" ..." if len(unmatched) > 10 else "") chunks.append( f"\n[... {len(unmatched)} file diffs unavailable (pathspec did not " f"match after decoding): {shown} ...]\n" ) with open(out_path, "w", encoding="utf-8") as fh: fh.write("".join(chunks)) print( f"Diff input: {len(files) - len(dropped) - len(unmatched)}/{len(files)} " f"file patches + complete diffstat, {written} chars" + (f"; {len(unmatched)} unmatched pathspec(s): {unmatched[:10]}" if unmatched else ""), file=sys.stderr, ) PYEOF then echo "WARNING: local git diff failed — proceeding without diff" : > "$DIFF_FILE" fi # NUL bytes break jq --rawfile JSON encoding; strip them. tr -d '\000' < "$DIFF_FILE" > "${DIFF_FILE}.clean" && mv "${DIFF_FILE}.clean" "$DIFF_FILE" echo "Diff size: $(wc -c < "$DIFF_FILE") bytes" else echo "WARNING: could not fetch tag ${PREV_TAG} — proceeding without diff" fi else echo "No previous stable release found — skipping diff" fi # ---- AI narrative (best-effort) ------------------------------------- AI_SUMMARY="" if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then echo "OPENROUTER_API_KEY not set — skipping AI summary" else # Compose the prompt: instructions (from $SUMMARY_PROMPT env) # followed by labelled input sections. Order matters for # truncation safety — diff is last because it's the largest # variable input and gets trimmed first if the overall prompt # exceeds the 750k char safety cap. { printf '%s\n' "$SUMMARY_PROMPT" printf '\n----- HAND-WRITTEN NOTES -----\n' if [[ -n "$HAND_NOTES" ]]; then printf '%s\n' "$HAND_NOTES" else printf '(none)\n' fi printf '\n----- AUTO-GENERATED PR LIST -----\n%s\n' "$AUTO_BODY" if [[ -s "$PR_BODIES_FILE" ]]; then printf '\n----- PR DESCRIPTIONS -----\n' cat "$PR_BODIES_FILE" fi if [[ -s "$DIFF_FILE" ]]; then printf '\n----- DIFF (%s...%s) -----\n' "$PREV_TAG" "$RELEASE_TAG" cat "$DIFF_FILE" fi } > "$PROMPT_FILE" # Overall prompt safety cap. Kimi K2 Thinking has a 262k token # context; leaving 64k for max_tokens output means ~198k for input, # which at ~3 chars/token for code-heavy diff is ~600k chars. We # cap at 750k chars to absorb the per-char/token variance — if # PR descriptions are unusually long alongside a near-cap diff, # the tail (diff) gets trimmed first since it sits last. # Character-aware truncation (Python) — see diff section above. python3 - "$PROMPT_FILE" 750000 'prompt truncated at 750,000 chars' <<'PYEOF' import sys path, max_chars, marker = sys.argv[1], int(sys.argv[2]), sys.argv[3] with open(path, encoding='utf-8', errors='replace') as f: content = f.read() if len(content) > max_chars: content = content[:max_chars] + f"\n[... {marker} ...]\n" with open(path, 'w', encoding='utf-8') as f: f.write(content) print(f"Truncated to {len(content)} chars", file=sys.stderr) PYEOF # reasoning.max_tokens caps the thinking trace so the model # cannot burn the whole max_tokens budget on reasoning and emit # empty content. This was the root cause of the v1.6.10 release # AI step failing with finish_reason: length and empty .content. JSON_PAYLOAD=$(jq -n \ --arg model "$AI_MODEL" \ --rawfile content "$PROMPT_FILE" \ --argjson temperature "$AI_TEMPERATURE" \ --argjson max_tokens "$AI_MAX_TOKENS" \ --argjson reasoning_max_tokens "$AI_REASONING_MAX_TOKENS" \ '{model:$model, messages:[{role:"user", content:$content}], temperature:$temperature, max_tokens:$max_tokens, reasoning:{max_tokens:$reasoning_max_tokens}}') # Up to 2 attempts (1 retry) with a short backoff. The model # endpoint can hiccup on cold starts or transient rate-limits; # one retry covers nearly all of those without delaying releases. # Default --max-time is 900s (15 min) — kimi-k2-thinking and # other thinking models can spend many minutes reasoning before # producing output, so the previous 120s ceiling was too # tight (observed on the v1.6.8 release run). HTTP_CODE="000" for attempt in 1 2; do HTTP_CODE=$(printf '%s' "$JSON_PAYLOAD" | curl -sS \ -o "$AI_RESPONSE_FILE" \ -w '%{http_code}' \ --max-time "$AI_MAX_TIME" \ -X POST "https://openrouter.ai/api/v1/chat/completions" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $OPENROUTER_API_KEY" \ -H "HTTP-Referer: https://github.com/${GITHUB_REPOSITORY}" \ --data-binary @- || echo "000") if [[ "$HTTP_CODE" =~ ^2 ]]; then break fi echo "WARNING: AI summary attempt $attempt failed (HTTP $HTTP_CODE)" if [[ "$attempt" -eq 1 ]]; then echo "Retrying in 5s..." sleep 5 fi done if [[ "$HTTP_CODE" =~ ^2 ]]; then # OpenRouter normalizes Moonshot's thinking trace to .reasoning # (not .reasoning_content — that key is Moonshot's native API # shape, exposed only on direct calls). Try .content first; # fall back to .reasoning_content then .reasoning. The triple # fallback is defense-in-depth: provider normalization is in # flux and either variant may surface in the future. AI_SUMMARY=$(jq -r '.choices[0].message.content // empty' "$AI_RESPONSE_FILE" 2>/dev/null || true) if [[ -z "$AI_SUMMARY" ]]; then AI_SUMMARY=$(jq -r '.choices[0].message.reasoning_content // empty' "$AI_RESPONSE_FILE" 2>/dev/null || true) [[ -n "$AI_SUMMARY" ]] && echo "NOTE: empty .content; using .reasoning_content from $AI_MODEL" fi if [[ -z "$AI_SUMMARY" ]]; then AI_SUMMARY=$(jq -r '.choices[0].message.reasoning // empty' "$AI_RESPONSE_FILE" 2>/dev/null || true) [[ -n "$AI_SUMMARY" ]] && echo "NOTE: empty .content/.reasoning_content; using .reasoning from $AI_MODEL" fi if [[ -n "$AI_SUMMARY" ]]; then echo "AI summary generated (model=$AI_MODEL, ${#AI_SUMMARY} chars)" # Hallucination guard: every reference the model emitted # (#NNNN, bare pull/issues URL, GH-NNNN — all forms GitHub # renders as a reference link) must appear in the INPUT # sections of the prompt it was given. # The prompt merely INSTRUCTS the model never to invent PR # numbers; instructions are not enforcement, and this text # publishes sight-unseen. On any unknown reference, drop # the whole AI section loudly — the release proceeds with # towncrier notes + the auto PR list, the same degrade # path as a missing OPENROUTER_API_KEY. The check reads # $PROMPT_FILE post-truncation, i.e. exactly what the # model saw. It cannot catch invented BEHAVIOR claims, # only invented references — but those are the most # embarrassing and the only mechanically detectable kind. # All-numeric hex colors ("#123456") can false-positive; # that costs us the AI section, never a wrong note. printf '%s' "$AI_SUMMARY" > "$AI_SUMMARY_FILE" if ! python3 - "$PROMPT_FILE" "$AI_SUMMARY_FILE" <<'PYEOF' import os import re import sys with open(sys.argv[1], encoding="utf-8", errors="replace") as f: prompt = f.read() with open(sys.argv[2], encoding="utf-8", errors="replace") as f: summary = f.read() # Only the INPUT sections feed the allowed set. The instruction # text above the first "----- " header contains a literal # format example ("Reference PRs as `#1234`") that would # otherwise self-whitelist #1234 — a real PR in this repo — for # the single most predictable parroting artifact. cut = prompt.find("\n----- ") inputs = prompt[cut:] if cut != -1 else prompt # Both sides scan the same two LOCAL reference forms: # - "#NNNN" with a (?/dev/null || echo '')" echo " message keys: $(jq -r '.choices[0].message | keys | join(", ")' "$AI_RESPONSE_FILE" 2>/dev/null || echo '')" echo " finish_reason: $(jq -r '.choices[0].finish_reason // ""' "$AI_RESPONSE_FILE" 2>/dev/null)" echo " error field: $(jq -r '.error // empty' "$AI_RESPONSE_FILE" 2>/dev/null)" fi else echo "WARNING: AI summary call failed (HTTP $HTTP_CODE) — skipping summary" fi fi # ---- end AI narrative ----------------------------------------------- { if [[ -n "$AI_SUMMARY" ]]; then printf '%s\n\n' "$AI_SUMMARY" fi if [[ -n "$HAND_NOTES" ]]; then printf '%s\n\n' "$HAND_NOTES" fi if [[ -n "$AI_SUMMARY" || -n "$HAND_NOTES" ]]; then printf -- '---\n\n' fi printf '%s\n' "$AUTO_BODY" } > "$NOTES_FILE" # GitHub release body limit is 125,000 chars — the API rejects # longer bodies with HTTP 422 and gh CLI does not pre-validate. # Truncate from the tail (the auto PR list is the largest variable # section in big releases) with a visible marker. Character-aware # truncation (Python) — see diff section above. python3 - "$NOTES_FILE" 124400 "[notes truncated to fit GitHub's 125,000-char body limit]" <<'PYEOF' import sys path, max_chars, marker = sys.argv[1], int(sys.argv[2]), sys.argv[3] with open(path, encoding='utf-8', errors='replace') as f: content = f.read() if len(content) > max_chars: content = content[:max_chars] + f"\n\n_{marker}_\n" with open(path, 'w', encoding='utf-8') as f: f.write(content) print(f"Release body truncated to {len(content)} chars", file=sys.stderr) PYEOF # Includes filesystem SBOM (Trivy scan of source tree) AND # container-image SBOM (Syft scan of built image layers, per-arch), # Sigstore signatures, and SLSA provenance. The two SBOM kinds # describe different surfaces — both are useful for downstream # supply-chain consumers. # `find ... -print0 | xargs -0 ...` handles the variable number # of per-arch container SBOMs gracefully (zero or more). set -euo pipefail CONTAINER_SBOMS=() while IFS= read -r -d '' f; do CONTAINER_SBOMS+=("$f") done < <(find . -maxdepth 1 -name 'sbom-container*.spdx.json' -print0) # --target pins the git tag this call MINTS to the gated release # commit. Without it, the Releases API tags default-branch HEAD # at create-release time — which, after a human-paced approval # delay, is a LATER, un-gated commit: the tag-side twin of the # PyPI dispatch-time-HEAD drift fixed in this same change (on # v1.9.0 that would have tagged a commit 4 PRs past the one the # gates ran against). Ignored when the tag already exists (e.g. # tag-push-triggered runs), so it is safe in every trigger mode. gh release create "$RELEASE_TAG" \ --target "$GITHUB_SHA" \ --repo "$GITHUB_REPOSITORY" \ --title "Release $RELEASE_VERSION" \ --notes-file "$NOTES_FILE" \ sbom-spdx.json \ sbom-spdx.json.bundle \ sbom-cyclonedx.json \ sbom-cyclonedx.json.bundle \ provenance.intoto.jsonl \ "${CONTAINER_SBOMS[@]}" env: # PAT_TOKEN is required here because GITHUB_TOKEN cannot trigger # workflows that listen on `release:` (backwards-compatibility.yml, # sbom.yml) — that's a documented GITHUB_TOKEN limitation. # Minimum scope: `repo` (full scope; `public_repo` is NOT sufficient # for the release-creation API on private repos and `repo` works # uniformly). The `workflow` scope is NOT needed — it only governs # editing files under .github/workflows/ via the API, which this # step does not do. GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} RELEASE_TAG: ${{ needs.build.outputs.tag }} RELEASE_VERSION: ${{ needs.build.outputs.version }} # Optional AI summarization. If OPENROUTER_API_KEY is not configured # in the release environment, the step skips the AI section silently. OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} AI_MODEL: ${{ vars.AI_MODEL || 'moonshotai/kimi-k2-thinking' }} AI_TEMPERATURE: ${{ vars.AI_RELEASE_SUMMARY_TEMPERATURE || '0.3' }} # 64000 matches the AI code-reviewer's working budget. The previous # 4000-token cap was the root cause of the v1.6.10 release AI failure: # kimi-k2-thinking burned the whole budget on reasoning tokens (which # count against max_tokens) and emitted finish_reason=length with an # empty .content. Reasoning tokens are pricey ($2.50/M output rate), # so reasoning.max_tokens is capped separately below. AI_MAX_TOKENS: ${{ vars.AI_RELEASE_SUMMARY_MAX_TOKENS || '64000' }} # Caps the thinking trace so the model cannot exhaust max_tokens # before emitting visible content. 16000 is well above Kimi's typical # 5k-15k reasoning footprint for summarization without giving it # room to spiral on a large diff. AI_REASONING_MAX_TOKENS: ${{ vars.AI_RELEASE_SUMMARY_REASONING_MAX_TOKENS || '16000' }} # Curl --max-time for the OpenRouter call. Default 900s (15 min) — # thinking models need substantial headroom on a multi-PR release # prompt (the previous 120s default timed out on v1.6.8 with # kimi-k2-thinking). Override via vars.AI_RELEASE_SUMMARY_MAX_TIME # if you want the AI step to fail faster on stuck calls. AI_MAX_TIME: ${{ vars.AI_RELEASE_SUMMARY_MAX_TIME || '900' }} SUMMARY_PROMPT: > You are writing the top section of a GitHub release page for an open-source project. The audience is end users of the software who want to know what changed and whether this release matters to them. Several inputs follow, each introduced by a header line of the form `----- HAND-WRITTEN NOTES -----`, `----- AUTO-GENERATED PR LIST -----`, `----- PR DESCRIPTIONS -----`, `----- DIFF (oldtag...newtag) -----`: - HAND-WRITTEN NOTES: a maintainer-curated changelog rendered from per-PR fragments. May be empty for maintenance releases. When present, these are the most authoritative description of intent. They appear verbatim below your text in the published release page — do not repeat them line-by-line. - AUTO-GENERATED PR LIST: GitHub's label-categorized list of merged PRs for this release. Useful for completeness; weak signal for intent. This list also appears verbatim below your text — do not repeat it. - PR DESCRIPTIONS: the body text of each merged PR. Often explains the why behind a change better than the title. - DIFF: the actual code change between the previous release and this one. Starts with a complete diffstat of every changed file, followed by per-file patches ordered by importance (source code first, largest changes first); low-priority patches may be omitted to fit, but the diffstat is always complete. Use to disambiguate what a PR actually did or to catch user-visible changes not well described in PR text. Write a helpful narrative that goes at the top of the release page. Lead with the most user-visible changes. Flag breaking changes early with a brief "what to do" if applicable. Group related changes when natural. Skip dependency bumps, internal CI, refactors, and docs-only changes unless they're load-bearing for users. Size the writeup to the material. A small maintenance release might get one short paragraph; a substantial release with new features and breaking changes might warrant several paragraphs with subsections. Don't pad. Don't open with a meta-preamble like "this release contains X PRs across Y categories" — just describe what happened. Output GitHub-flavored markdown. Open with a `##` heading naming the theme of the release (e.g. `## Friendlier OpenAI-compatible error messages and multi-migration upgrade fixes`, or for small releases simply `## What's new`). Use `###` for any subsections. Do not emit `#` (the release title above is the H1). Reference PRs as `#1234` only when their numbers appear in the inputs — never invent PR numbers, features, or behavior. If the hand-written notes contradict the PR list or diff, trust the hand-written notes; they are the authoritative human narrative. No preamble, no closing meta-commentary. Just the section. # Persists the towncrier render back to main. The create-release job runs # `towncrier build` in a throwaway sparse checkout to produce the rendered # release notes for the GitHub release body, but those changes (consumed # fragments + new docs/release_notes/.md) never reach the repo # from there. Without this job, the next release would re-render all the # same fragments. Branch protection on main has 0 required reviews and # 0 required status checks, so the maintainer can squash-merge this PR # without ceremony. cleanup-changelog: needs: [build, create-release] if: ${{ !cancelled() && needs.create-release.result == 'success' && needs.build.outputs.release_exists == 'false' }} runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Checkout the release commit # Pinning to ${{ github.sha }} (not `ref: main`) avoids a race: # if a new PR with new fragments merges into main between # create-release and cleanup-changelog, `ref: main` would consume # those new fragments into THIS release's docs/release_notes file # and delete them prematurely — misattributing them and stealing # them from the next release. github.sha is the commit that # triggered the workflow run (same commit create-release rendered # against), so the set of fragments is identical. uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ github.sha }} fetch-depth: 1 # Drop the credential helper that `actions/checkout` configures by # default. Without this, the job's GITHUB_TOKEN is persisted into # `.git/config` for the duration of the run; if any later step in # this job uploads the workspace as an artifact (or anything else # later reads `.git/config`), the token leaks. Closes zizmor # finding `zizmor/artipacked` (alert #4655). The downstream # `peter-evans/create-pull-request` step accepts an explicit # `token:` input and does not rely on the persisted git helper, # so removing this credential has no functional impact. persist-credentials: false - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.12' - name: Install towncrier run: pip install towncrier==24.8.0 - name: Render and consume fragments env: RELEASE_VERSION: ${{ needs.build.outputs.version }} run: | set -euo pipefail if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][a-zA-Z0-9.+_-]+)?$ ]]; then echo "ERROR: RELEASE_VERSION '$RELEASE_VERSION' is not a valid bare semver string" >&2 exit 1 fi # Skip cleanly when there are no fragments — nothing to clean up, # no PR needed. README.md is preserved per the contributor guide # in changelog.d/README.md. if [[ "$(find changelog.d -maxdepth 1 -name '*.md' ! -name 'README.md' | wc -l)" -eq 0 ]]; then echo "No changelog fragments to clean up — skipping PR" echo "skip=true" >> "$GITHUB_ENV" exit 0 fi towncrier build --yes --version "$RELEASE_VERSION" echo "skip=false" >> "$GITHUB_ENV" - name: Open cleanup PR if: env.skip != 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} # Pin base explicitly: when this workflow is triggered by a tag # push, github.sha may not sit on main HEAD, and the action's # default-branch resolution could pick a non-main base. We always # want this PR to target main. base: main commit-message: "chore: clear changelog fragments for ${{ needs.build.outputs.version }}" branch: "chore/post-release-cleanup-${{ needs.build.outputs.version }}" delete-branch: true title: "chore: clear changelog fragments for ${{ needs.build.outputs.version }}" body: | ## Summary Post-release housekeeping for **${{ needs.build.outputs.version }}**: - Renders `changelog.d/` fragments into `docs/release_notes/${{ needs.build.outputs.version }}.md` - Removes the consumed fragments The same render was already used to compose the GitHub release body for [${{ needs.build.outputs.tag }}](https://github.com/${{ github.repository }}/releases/tag/${{ needs.build.outputs.tag }}). This PR exists only to persist the cleanup so the next release does not re-render the same fragments. Auto-merge-friendly: no review value beyond a sanity check that the rendered notes look right. labels: | automation maintenance # NOTE: trigger-prerelease-docker and the old combined trigger-workflows # (which used to dispatch BOTH publish-docker AND publish-pypi) were # removed in the build-once-promote + atomicity refactors. # prerelease-docker.yml and docker-publish.yml are now invoked as # reusable workflows earlier in this file. Only PyPI publishing remains # on repository_dispatch — PyPI Trusted Publishing requires the publish # step to run in a top-level (non-reusable) workflow. # Dispatch PyPI publish via repository_dispatch. Kept as a dispatch # (rather than a reusable workflow_call) because PyPI's Trusted # Publisher matches the OIDC `workflow_ref` claim, which points to the # CALLER when a workflow is invoked via workflow_call — so a reusable # publish.yml would fail with `invalid-publisher`. Tracked in # pypa/gh-action-pypi-publish#166 and pypi/warehouse#11096. trigger-pypi: needs: [build, prerelease-docker, publish-docker] if: ${{ !cancelled() && needs.prerelease-docker.result == 'success' && needs.publish-docker.result == 'success' && needs.build.outputs.release_exists == 'false' }} runs-on: ubuntu-latest environment: release permissions: contents: read outputs: dispatch_time: ${{ steps.dispatch-time.outputs.timestamp }} steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit # Recorded BEFORE the dispatch so monitor-pypi can filter on createdAt # without missing the run created during this job. - name: Record dispatch time id: dispatch-time run: echo "timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" - name: Trigger PyPI publish workflow uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RELEASE_TAG: ${{ needs.build.outputs.tag }} RELEASE_SHA: ${{ github.sha }} with: # PAT_TOKEN: repository_dispatch from a workflow run cannot be # fired with GITHUB_TOKEN — the API rejects it to prevent # workflow-trigger loops. Minimum scope: `repo` (full scope — # `public_repo` is rejected by createDispatchEvent regardless # of repo visibility). github-token: ${{ secrets.PAT_TOKEN }} script: | // Forward `prerelease: false` explicitly. publish.yml gates // Test PyPI vs prod PyPI on `client_payload.prerelease == true`; // if absent, the expression evaluates to '' and falls through to // prod PyPI. Setting false here makes the choice explicit. The // current pipeline only releases stable versions through this // dispatch — true prereleases (if added later) would need a // separate trigger path that flips this flag. // // `sha` pins publish.yml's checkout to the release commit. // repository_dispatch runs on default-branch HEAD *at dispatch // time*, and the `release` env approval is human-paced — on // v1.9.0 the approval came ~30h after the release commit, so // publish.yml built from a HEAD that was 4 PRs ahead of the // tag: PyPI 1.9.0 contains code that is not in the v1.9.0 // tag, Docker image, or release notes. The SHA (not the tag) // must be passed because create-release creates the git tag // only AFTER PyPI succeeds — the tag does not exist yet here. await github.rest.repos.createDispatchEvent({ owner: context.repo.owner, repo: context.repo.repo, event_type: 'publish-pypi', client_payload: { tag: process.env.RELEASE_TAG, sha: process.env.RELEASE_SHA, prerelease: false } }); console.log('Triggered PyPI publish workflow'); # Block on the dispatched publish.yml run so create-release downstream # only fires once PyPI has actually shipped. If PyPI fails, this job # fails and create-release is skipped — preventing the GH Release from # publishing with a missing PyPI artifact. (Docker promote already # blocked synchronously above via publish-docker reusable call.) monitor-pypi: needs: [build, trigger-pypi] if: ${{ !cancelled() && needs.trigger-pypi.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 90 permissions: contents: read actions: read # Required for gh run list issues: write steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Wait for PyPI publish workflow to complete id: wait env: GH_TOKEN: ${{ github.token }} # gh CLI cannot infer the repo here (this job has no checkout step), # and it does NOT fall back to GITHUB_REPOSITORY. Without GH_REPO, # `gh run list` fails with "failed to determine base repo", the # error is swallowed, and every poll sees an empty result. GH_REPO: ${{ github.repository }} RELEASE_TAG: ${{ needs.build.outputs.tag }} DISPATCH_TIME: ${{ needs.trigger-pypi.outputs.dispatch_time }} run: | # pipefail is essential here: the poll loop pipes `gh run list` # into `jq`, and without pipefail a transient `gh` failure # (network, auth, rate limit) is swallowed by `jq` returning # empty input, causing the loop to spin silently for the full # 40-minute budget rather than surfacing the error immediately. set -euo pipefail echo "Monitoring publish.yml for tag $RELEASE_TAG (dispatched at $DISPATCH_TIME)..." # Wait for the dispatched run to start (repository_dispatch is async) sleep 30 max_wait=2400 # 40 minutes elapsed=0 conclusion="" while [ "$elapsed" -lt "$max_wait" ]; do # Compare timestamps numerically — lexicographic comparison # breaks when GitHub returns sub-second precision (e.g. # "...:56.500Z" sorts before "...:56Z"). fromdateiso8601 # doesn't accept fractional seconds, so strip them first. The # 60s buffer tolerates minor clock skew between the runner and # GitHub's API. Stderr is intentionally NOT redirected so gh # failures stay visible in the runner log. run=$(gh run list --workflow=publish.yml --limit=20 --json status,conclusion,createdAt \ | jq -r --arg since "$DISPATCH_TIME" ' def to_epoch: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601; (($since | to_epoch) - 60) as $s | [.[] | select((.createdAt | to_epoch) >= $s)] | .[0] ') if [ "$run" = "null" ] || [ -z "$run" ]; then echo "publish.yml: waiting for run to appear..." >&2 sleep 30 elapsed=$((elapsed + 30)) continue fi status=$(echo "$run" | jq -r '.status') conclusion=$(echo "$run" | jq -r '.conclusion') if [ "$status" = "completed" ]; then echo "publish.yml: $conclusion" >&2 break fi sleep 30 elapsed=$((elapsed + 30)) done if [ -z "$conclusion" ]; then echo "::error::publish.yml run did not start or complete within ${max_wait}s" echo "pypi_result=timed_out" >> "$GITHUB_OUTPUT" exit 1 fi echo "pypi_result=$conclusion" >> "$GITHUB_OUTPUT" if [ "$conclusion" = "success" ]; then echo "PyPI publish completed successfully" exit 0 else echo "::error::PyPI publish completed with conclusion: $conclusion" exit 1 fi - name: Create issue on PyPI publish failure # Always run on failure so maintainers see a clear, persistent # record that captures the workflow run ID. The Actions UI shows # the failure too but issues are easier to search and triage. if: failure() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RELEASE_TAG: ${{ needs.build.outputs.tag }} PYPI_RESULT: ${{ steps.wait.outputs.pypi_result }} with: script: | const { owner, repo } = context.repo; const tag = process.env.RELEASE_TAG; const pypi = process.env.PYPI_RESULT || 'unknown'; const title = `PyPI publish failure for ${tag}`; // publish.yml fails closed without a full 40-hex client_payload.sha // (the pinned release commit — NOT main HEAD, which may have moved // during the approval wait). Embed the exact ready-to-paste // re-dispatch command so an on-call maintainer under incident // pressure cannot follow a stale two-field payload shape. const redispatch = '```\ngh api repos/' + owner + '/' + repo + '/dispatches \\\n' + " -f event_type=publish-pypi \\\n" + " -F 'client_payload[tag]=" + tag + "' \\\n" + " -F 'client_payload[sha]=" + context.sha + "'\n```"; const action = pypi === 'timed_out' ? '**Suggested action:** publish.yml did not complete within 40 minutes. Check the [Actions tab](https://github.com/' + owner + '/' + repo + '/actions)' + ' — if the run never appeared, this likely indicates a dispatch issue; re-trigger with:\n' + redispatch + '\nIf still running, no action may be needed.' : '**Suggested action:** Inspect publish.yml logs in the [Actions tab](https://github.com/' + owner + '/' + repo + '/actions), fix the underlying cause,' + ' then either (a) re-dispatch publish.yml with:\n' + redispatch + '\nand re-run release.yml to complete create-release, or (b) manually publish the GitHub Release if Docker promote also succeeded.' + ' See docs/RELEASE_GUIDE.md "Recovery from PyPI failure".'; const body = `## PyPI publish failed for ${tag}\n\nResult: \`${pypi}\`\n\nNote: At this point in the atomicity flow, Docker promote (publish-docker job) has already succeeded — the Docker release tags exist and are signed. Only PyPI and the GitHub Release are missing.\n\n${action}`; // De-dup: if an open issue with the same title already exists, // comment on it instead of opening a duplicate. const existing = await github.paginate(github.rest.issues.listForRepo, { owner, repo, state: 'open', labels: 'ci-cd', per_page: 100, }); const match = existing.find(i => i.title === title && !i.pull_request); if (match) { await github.rest.issues.createComment({ owner, repo, issue_number: match.number, body: `Re-detected on workflow run [${context.runId}](https://github.com/${owner}/${repo}/actions/runs/${context.runId}):\n\n${body}`, }); } else { await github.rest.issues.create({ owner, repo, title, body, labels: ['ci-cd'], }); } # ============================================================================ # CLEANUP ON REJECTION # ============================================================================ # In the build-once-promote model, prerelease-docker signs the manifest # BEFORE the publish step runs. If publish-docker (the retag step) fails # or the maintainer rejects the `release` env approval, the prerelease # tag and its cosign artifacts (`sha256-.{sig,att}`) are left # orphaned on Docker Hub forever — the existing cleanup loop inside # docker-publish.yml only runs on its success path. # # SAFETY: cosign signature/attestation artifacts are stored at a tag named # `sha256-.{sig,att}` and discovered by manifest digest, # NOT by image tag. After publish-docker SUCCEEDS, the release tags # (`:VERSION`, `:MAJOR_MINOR`, `:latest`) share the prerelease manifest # digest (imagetools retag preserves the digest), so the cosign artifacts # anchor BOTH the deleted prerelease tag AND the live release tags. # Deleting them after a successful retag would invalidate release-tag # cosign verification. # # Therefore: this job ONLY fires when publish-docker did not succeed. # Beyond that point, docker-publish.yml's success-path cleanup handles # prerelease-tag deletion, and cosign artifacts must stay. # # Edge case: partial retag failure (e.g., `:1.6.9` lands but `:latest` # fails) — publish-docker exits failure with some release tags already # created. The cleanup script enumerates the three possible release tags # against Docker Hub and rolls back any that exist BEFORE deleting # cosign artifacts. This is the only case where we delete release tags # rather than leave them. # ============================================================================ cleanup-on-rejection: name: Clean up orphan prerelease tags and signatures needs: [build, prerelease-docker, publish-docker] if: >- ${{ always() && needs.prerelease-docker.result != 'skipped' && needs.build.outputs.release_exists == 'false' && (needs.prerelease-docker.result == 'failure' || needs.prerelease-docker.result == 'cancelled' || needs.publish-docker.result == 'failure' || needs.publish-docker.result == 'cancelled') }} runs-on: ubuntu-latest # DOCKER_USERNAME / DOCKER_PASSWORD are env-scoped to `release` # (deliberately not repo-level — see comment on the build job above). # Without `environment: release` here, those secrets resolve to empty # strings and the Docker Hub login at the bottom of this job exits 1, # leaving the orphan tags + cosign artifacts the cleanup is meant to # remove. The `release` env approval was already granted upstream in # this run, so this does not add a new prompt. environment: release permissions: contents: read steps: - name: Harden the runner (Audit all outbound calls) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 with: egress-policy: audit - name: Roll back partial release tags then delete prerelease + cosign artifacts env: DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} VERSION: ${{ needs.build.outputs.version }} SHORT_SHA: ${{ needs.build.outputs.short_sha }} DIGEST: ${{ needs.prerelease-docker.outputs.manifest_digest }} run: | set -euo pipefail # Authenticate with Docker Hub API (same JWT flow as docker-publish.yml cleanup) TOKEN=$(curl -sS -X POST -H "Content-Type: application/json" \ -d "{\"username\":\"${DOCKER_USERNAME}\",\"password\":\"${DOCKER_PASSWORD}\"}" \ https://hub.docker.com/v2/users/login/ | jq -r .token) if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then echo "::error::Failed to authenticate with Docker Hub API — cannot clean up orphans" exit 1 fi REPO="${DOCKER_USERNAME}/local-deep-research" MANIFEST_TAG="prerelease-v${VERSION}-${SHORT_SHA}" MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2) delete_tag() { local TAG="$1" local STATUS STATUS=$(curl -sS -o /dev/null -w "%{http_code}" -X DELETE \ -H "Authorization: JWT ${TOKEN}" \ "https://hub.docker.com/v2/repositories/${REPO}/tags/${TAG}/" || echo "ERR") case "$STATUS" in 200|204) echo "Deleted ${TAG}";; 404) echo "Skip ${TAG} (already absent — expected)";; 401|403) echo "::error::Auth failure deleting ${TAG} — DOCKER_PASSWORD may be missing Delete scope on the Docker Hub PAT" exit 1 ;; *) echo "::warning::Unexpected HTTP ${STATUS} for ${TAG}";; esac } # ===================================================================== # STEP 1 — Partial retag rollback. # ===================================================================== # If publish-docker failed mid-way through `docker buildx imagetools # create -t :VERSION -t :MAJOR_MINOR -t :latest`, one or two of the # three release tags may have landed. Those tags would point at the # prerelease manifest digest, which we're about to delete cosign # artifacts for. We MUST roll back any landed release tags BEFORE # touching cosign — otherwise the rollback leaves them broken. # # If publish-docker was skipped (prerelease failed before reaching # retag), the release tags can't exist, so this loop is a cheap no-op # (three 404 responses). # ===================================================================== echo "STEP 1 — Roll back any release tags that landed during a partial retag..." for RELEASE_TAG in "${VERSION}" "${MAJOR_MINOR}" "latest"; do # HEAD-check before DELETE so the "expected absent" case is silent. # Docker Hub's tag endpoint returns 200 on HEAD if tag exists, 404 if not. CHECK=$(curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: JWT ${TOKEN}" \ "https://hub.docker.com/v2/repositories/${REPO}/tags/${RELEASE_TAG}/" || echo "ERR") if [ "$CHECK" = "200" ]; then echo "::warning::Release tag ${RELEASE_TAG} exists from a partial retag — rolling back" delete_tag "${RELEASE_TAG}" else echo "Release tag ${RELEASE_TAG}: not present (HTTP ${CHECK}) — nothing to roll back" fi done # ===================================================================== # STEP 2 — Prerelease tag cleanup. # ===================================================================== # Always-safe targets: the prerelease manifest list + its per-arch # children + the floating `:prerelease` tag. Cleanup is best-effort # here (404s are normal — e.g., build failed before pushing arm64). # # The floating `:prerelease` tag (re-pointed by prerelease-docker.yml # to the current run's manifest) is included so a rejected release # does not leave `:prerelease` pointing at a manifest whose cosign # artifacts step 4 below is about to delete — pulling `:prerelease` # in that window would yield an image whose signature is gone, and # the README cosign-verify recipe would fail. Returning 404 on # `:prerelease` is unambiguously safer than serving an unverifiable # image; the next successful prerelease-docker run re-creates it. # ===================================================================== echo "STEP 2 — Delete prerelease tags..." for TAG in "${MANIFEST_TAG}" "${MANIFEST_TAG}-amd64" "${MANIFEST_TAG}-arm64" "prerelease"; do delete_tag "${TAG}" done # ===================================================================== # STEP 3 — Discover per-arch digests from the manifest list BEFORE # we delete cosign artifacts. # ===================================================================== # prerelease-docker.yml's per-arch SBOM step attests against each # per-arch digest, producing artifacts at # `sha256-.{sig,att,sbom}` in addition to the # manifest-list-digest artifacts. Need to enumerate from the manifest # while it still exists (we just deleted the TAG, but the manifest # body persists until Docker Hub GC, and we can inspect by digest). # Use the captured DIGEST to inspect the manifest list directly. # ===================================================================== PER_ARCH_TAGS=() if [[ -n "${DIGEST:-}" && "$DIGEST" == sha256:* ]]; then echo "STEP 3 — Discovering per-arch digests from manifest list ${DIGEST}..." if docker buildx imagetools inspect "${REPO}@${DIGEST}" --raw > /tmp/manifest.json 2>/dev/null; then while IFS= read -r PER_ARCH_DIGEST; do if [[ -n "${PER_ARCH_DIGEST}" && "${PER_ARCH_DIGEST}" == sha256:* ]]; then PER_ARCH_TAG_PREFIX="${PER_ARCH_DIGEST/:/-}" PER_ARCH_TAGS+=( "${PER_ARCH_TAG_PREFIX}.sig" "${PER_ARCH_TAG_PREFIX}.att" "${PER_ARCH_TAG_PREFIX}.sbom" ) echo " queued cleanup for per-arch artifacts at ${PER_ARCH_DIGEST}" fi done < <(jq -r '.manifests[] | select(.platform.architecture != "unknown") | .digest' /tmp/manifest.json) else echo "::warning::Could not inspect manifest at ${DIGEST} — skipping per-arch attestation cleanup" fi else echo "STEP 3 — No valid manifest digest captured (got '${DIGEST:-}'); skipping per-arch discovery" fi # ===================================================================== # STEP 4 — Cosign signature/attestation artifact cleanup. # ===================================================================== # Safe to delete only because STEP 1 already rolled back any release # tags that might be sharing these digests. The .sbom entries are # legacy from `cosign attach sbom` (current code uses `cosign attest # --type spdxjson` which writes to .att) — kept as belt-and-suspenders # cleanup for any leftovers from older releases. # ===================================================================== COSIGN_TAGS=() if [[ -n "${DIGEST:-}" && "$DIGEST" == sha256:* ]]; then DIGEST_TAG_PREFIX="${DIGEST/:/-}" COSIGN_TAGS+=( "${DIGEST_TAG_PREFIX}.sig" "${DIGEST_TAG_PREFIX}.att" "${DIGEST_TAG_PREFIX}.sbom" ) fi COSIGN_TAGS+=("${PER_ARCH_TAGS[@]}") if [ "${#COSIGN_TAGS[@]}" -gt 0 ]; then echo "STEP 4 — Delete cosign signature/attestation artifacts..." for TAG in "${COSIGN_TAGS[@]}"; do delete_tag "${TAG}" done else echo "STEP 4 — No cosign artifact tags to clean up (no captured digest)" fi # NOTE: NO `continue-on-error: true` at job level — auth failures # (401/403) are loud so missing token scopes can't silently let # orphans accumulate forever.