Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1898 lines
97 KiB
YAML

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-<digest>.{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/<version>.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/<num> 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 (?<![\w/]) lookbehind, so the cross-repo
# shorthand "owner/repo#166" (rendered by GitHub as a link to
# the OTHER repo) neither whitelists a local number on the
# input side nor counts as a local claim on the output side.
# The lookbehind also skips inline anchors whose "#" follows
# a word char or slash ("page#4-setup", "docs/#4").
# - pull/issues URLs scoped to THIS repository — a cross-repo
# URL in a PR body must not whitelist its number locally, and
# the model repeating one must not trip the guard.
# GITHUB_REPOSITORY is always set in Actions; if it were ever
# missing, the KeyError exits non-zero and the AI section is
# dropped (safe direction).
repo = re.escape(os.environ["GITHUB_REPOSITORY"])
allowed = set(re.findall(r"(?<![\w/])#(\d+)\b", inputs))
allowed |= set(re.findall(repo + r"/(?:pull|issues)/(\d+)\b", inputs))
# Emitted references: capture local URL forms first, then strip
# URLs from the summary so their numeric fragment anchors
# ("https://ex.com/p#4-setup") can't count as prose references,
# then scan the remaining prose for "#NNNN" and "GH-NNNN".
# (Round 1 used a (?!-) lookahead against anchors, but that
# silently removed any "#9999-suffixed" reference from
# detection entirely — a false-NEGATIVE hole, the wrong
# direction for a guard. URL-stripping kills the anchor
# false-positive without blinding the scan.)
url_refs = set(re.findall(repo + r"/(?:pull|issues)/(\d+)\b", summary))
prose = re.sub(r"https?://\S+", " ", summary)
emitted = set(re.findall(r"(?<![\w/])#(\d+)\b", prose))
emitted |= url_refs
emitted |= set(re.findall(r"\bGH-(\d+)\b", prose))
unknown = sorted(emitted - allowed, key=int)
if unknown:
print(
"AI summary references numbers absent from its input: "
+ ", ".join("#" + n for n in unknown),
file=sys.stderr,
)
sys.exit(1)
print(
f"AI reference check passed: {len(emitted)} reference(s), "
"all present in the prompt",
file=sys.stderr,
)
PYEOF
then
echo "WARNING: AI summary contains references not present in its input — dropping AI section (release proceeds with towncrier notes + PR list)"
AI_SUMMARY=""
fi
else
echo "WARNING: AI response 2xx but no usable content field — skipping summary"
# Diagnostic: dump the response shape so the next failure
# can be debugged without rerunning. Bounded to keep noise
# out of logs.
echo " response keys: $(jq -r 'keys | join(", ")' "$AI_RESPONSE_FILE" 2>/dev/null || echo '<unparseable>')"
echo " message keys: $(jq -r '.choices[0].message | keys | join(", ")' "$AI_RESPONSE_FILE" 2>/dev/null || echo '<missing>')"
echo " finish_reason: $(jq -r '.choices[0].finish_reason // "<none>"' "$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/<version>.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-<digest>.{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-<manifest-digest>.{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-<per-arch-digest>.{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:-<empty>}'); 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.