name: Draft release notes # At release CUT (a vX.Y.Z tag is pushed → the "GitHub Release" workflow creates # the draft), prepare everything the release coordinator needs before they hit # Publish: # # 1. Open a PR to omnigent/main updating the granular CHANGELOG.md (harvested # from each merged PR's "## Changelog" section), so the draft's # "Full Changelog" link resolves before the release goes public. # 2. Synthesize concise, curated release notes (an Omnigent agent collapses the # merged PRs into ~4-5 themed highlights per section) and drop them into the # GitHub Release DRAFT body for the coordinator to edit. # # Why `workflow_run` (not extending github-release.yml): that workflow is # deliberately minimal — it runs NO project code, only `gh release create`, so a # malicious tagged commit can't execute anything. We keep that guarantee by # running the heavy work (LLM + git harvest) in this SEPARATE workflow, which # runs from the trusted default branch (workflow_run always does), never from the # tagged commit. Same "harvester runs from main" posture as autoformat-pr.yml. # # The LLM machinery (creds gate, Claude Code CLI, provider config, secret-scan, # token-minted-after-agent, artifact redaction) mirrors doc-sync.yml. The agent # only ever sees already-merged, released history. on: workflow_run: workflows: ["GitHub Release"] types: [completed] workflow_dispatch: inputs: tag: description: Release tag/ref to (re)draft (head of the range), e.g. v0.3.0 required: true type: string base: description: >- Optional range-start override (tag/branch/sha). Needed when `tag` is not a final vX.Y.Z. Providing it makes the run a preview unless dry_run=false. required: false type: string dry_run: description: >- Preview only: auto (default) - preview for dev/rc tags, real PR for final versions; true - print the generated notes, don't open a PR; false - open a real PR to CHANGELOG.md required: false type: choice options: [auto, "true", "false"] default: auto permissions: contents: read concurrency: group: draft-release-notes-${{ github.event.workflow_run.head_branch || inputs.tag }} cancel-in-progress: false env: SOURCE_REPO: omnigent-ai/omnigent OMNIGENT_SKIP_WEB_UI: "true" UV_INDEX_URL: https://pypi.org/simple PIP_INDEX_URL: https://pypi.org/simple jobs: draft: name: Harvest CHANGELOG and draft release notes if: >- github.repository == 'omnigent-ai/omnigent' && (github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success') runs-on: ubuntu-latest timeout-minutes: 40 steps: # --- Resolve the tag and decide whether to proceed (no code run yet) --- - name: Resolve tag and guard id: guard env: GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} # On tag push, workflow_run.head_branch is the tag name (v0.3.0). RUN_BRANCH: ${{ github.event.workflow_run.head_branch }} INPUT_TAG: ${{ inputs.tag }} INPUT_BASE: ${{ inputs.base }} INPUT_DRY_RUN: ${{ inputs.dry_run }} run: | set -euo pipefail tag="${INPUT_TAG:-$RUN_BRANCH}" base="${INPUT_BASE:-}" proceed=false; dry_run=false # Does the tag look like a final release (vX.Y.Z, not rc/dev/alpha/beta)? is_version=true case "$tag" in v[0-9]*.[0-9]*.[0-9]*) ;; *) is_version=false ;; esac case "$tag" in *rc*|*dev*|*a[0-9]*|*b[0-9]*) is_version=false ;; esac if [ "$EVENT_NAME" = "workflow_run" ]; then # Real release cut: strict — only a final version tag proceeds. [ "$is_version" = "true" ] && proceed=true else # Manual dispatch: proceed for a final version tag OR when a base # override is given (arbitrary-ref preview/real run). if [ "$is_version" = "true" ] || [ -n "$base" ]; then proceed=true fi # dry_run: `auto` previews for a non-version tag or a base override, # and does a real run for a plain version tag; true/false force it. case "$INPUT_DRY_RUN" in true) dry_run=true ;; false) dry_run=false ;; *) if [ "$is_version" != "true" ] || [ -n "$base" ]; then dry_run=true; fi ;; esac fi # NOTE: we do NOT probe for the draft release here. This step runs with # the read-only GITHUB_TOKEN, and GitHub hides DRAFT releases from tokens # without push access — the probe would always come back empty and wrongly # report "no draft". Draft detection happens after the App token is minted # (see "Resolve draft release"), which can see drafts. echo "tag=${tag}" >> "$GITHUB_OUTPUT" echo "base=${base}" >> "$GITHUB_OUTPUT" echo "proceed=${proceed}" >> "$GITHUB_OUTPUT" echo "dry_run=${dry_run}" >> "$GITHUB_OUTPUT" echo "Resolved tag=${tag} base=${base:-} proceed=${proceed} dry_run=${dry_run}" \ | tee -a "$GITHUB_STEP_SUMMARY" # Trusted default branch, full history + tags for the range computation. - name: Checkout omnigent (main) if: steps.guard.outputs.proceed == 'true' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: main fetch-depth: 0 fetch-tags: true persist-credentials: false - name: Set up Python if: steps.guard.outputs.proceed == 'true' uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" # --- 1) Harvest CHANGELOG.md + the mechanical scaffold + agent input --- - name: Harvest changelog and PR material id: harvest if: steps.guard.outputs.proceed == 'true' env: GH_TOKEN: ${{ github.token }} TAG: ${{ steps.guard.outputs.tag }} BASE: ${{ steps.guard.outputs.base }} DRY_RUN: ${{ steps.guard.outputs.dry_run }} run: | set -euo pipefail # generate.py orders CHANGELOG.md by PEP 440 (packaging). This step runs # bare python3 (before uv sync), so ensure packaging is importable. python3 -m pip install --quiet --disable-pip-version-check packaging args=(--tag "$TAG" --repo "$SOURCE_REPO" --draft-notes-out /tmp/mechanical_notes.md --pr-list-out /tmp/pr_list.txt --section-out /tmp/section.md) [ -n "${BASE:-}" ] && args+=(--base "$BASE") if [ "$DRY_RUN" = "true" ]; then # Preview only — render, don't touch CHANGELOG.md. args+=(--no-changelog-update) else args+=(--changelog-file CHANGELOG.md) fi python3 .github/scripts/changelog/generate.py "${args[@]}" # The mechanical scaffold is the fallback release-notes body. cp /tmp/mechanical_notes.md /tmp/release_notes.md if [ "$DRY_RUN" = "true" ]; then { echo "## Preview — CHANGELOG.md section for \`${TAG}\`" echo '```markdown'; cat /tmp/section.md; echo '```' echo "## Preview — mechanical draft notes" echo '```markdown'; cat /tmp/mechanical_notes.md; echo '```' } >> "$GITHUB_STEP_SUMMARY" fi # --- 2) AI synthesis (primary; degrades to the mechanical scaffold) --- - name: Check LLM credentials id: creds if: steps.guard.outputs.proceed == 'true' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | if [ -z "${LLM_API_KEY:-}" ]; then echo "::warning::No LLM credentials — using the mechanical draft scaffold." echo "available=false" >> "$GITHUB_OUTPUT" else echo "::add-mask::${LLM_API_KEY}" echo "available=true" >> "$GITHUB_OUTPUT" fi - name: Set up uv if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true - name: Cache virtualenv if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: .venv key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }} - name: Install dependencies if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' run: uv sync --extra all --extra dev - name: Install Claude Code CLI if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: NPM_CONFIG_REGISTRY: https://registry.npmjs.org/ run: | mkdir -p "${GITHUB_WORKSPACE}/.cc-cli" && cd "${GITHUB_WORKSPACE}/.cc-cli" npm install --ignore-scripts --no-audit --no-fund @anthropic-ai/claude-code@2.1.170 node node_modules/@anthropic-ai/claude-code/install.cjs echo "${GITHUB_WORKSPACE}/.cc-cli/node_modules/.bin" >> "$GITHUB_PATH" - name: Write Omnigent provider config if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: GATEWAY_BASE_URL: ${{ secrets.GATEWAY_BASE_URL }} run: | mkdir -p "$HOME/.omnigent" python3 -c " import pathlib, os, json gw = os.environ['GATEWAY_BASE_URL'] cfg = {'providers': {'databricks-gateway': { 'kind': 'gateway', 'default': ['anthropic'], 'anthropic': { 'base_url': gw + '/anthropic', 'api_key_ref': 'env:LLM_API_KEY', 'models': {'default': 'databricks-claude-opus-4-8'}, }}}} pathlib.Path.home().joinpath('.omnigent', 'config.yaml').write_text(json.dumps(cfg, indent=2)) " - name: Build drafter prompt if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: TAG: ${{ steps.guard.outputs.tag }} run: | set -euo pipefail python3 -u <<'PYEOF' import os, pathlib tag = os.environ["TAG"] # The agent is tools-less, so its input must be inline — but `omnigent run # -p` passes the whole prompt as one argv string, capped at ~128 KiB on # Linux (MAX_ARG_STRLEN). Cap the PR list well under that; the mechanical # scaffold already covers everything, so a partial list still drafts. MAX = 100_000 pr_list = pathlib.Path("/tmp/pr_list.txt").read_text(encoding="utf-8", errors="replace") mech = pathlib.Path("/tmp/mechanical_notes.md").read_text(encoding="utf-8", errors="replace") truncated = len(pr_list) > MAX pr_list = pr_list[:MAX] note = ("\n> NOTE: the PR list was truncated — theme what's visible and keep the " "mechanical draft's coverage.\n" if truncated else "") prompt = f"""Draft the curated release notes for {tag}. {note} ## Merged PRs (number, title, and author changelog entries) {pr_list} ## Mechanical draft (raw material — curate, don't copy verbatim) {mech} Produce the RELEASE_NOTES block per your instructions.""" pathlib.Path("/tmp/draft_prompt.txt").write_text(prompt) PYEOF - name: Run release-notes drafter id: draft if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail prompt="$(cat /tmp/draft_prompt.txt)" uv run --project "${GITHUB_WORKSPACE}" omnigent run \ "${GITHUB_WORKSPACE}/.github/agents/release-notes-drafter" \ -p "$prompt" --no-session \ 2>draft-stderr.log | tee /tmp/draft_out.txt \ || { echo "::warning::drafter exited non-zero — keeping mechanical draft"; cat draft-stderr.log; } - name: Scan drafter output for secrets if: steps.draft.outcome == 'success' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail if [ -n "${LLM_API_KEY:-}" ] && grep -qF "$LLM_API_KEY" /tmp/draft_out.txt 2>/dev/null; then echo "::error::Drafter output contains LLM_API_KEY — aborting." exit 1 fi - name: Extract synthesized notes (fall back to mechanical) if: steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' run: | set -euo pipefail python3 -u <<'PYEOF' import pathlib, re raw = pathlib.Path("/tmp/draft_out.txt").read_text(encoding="utf-8", errors="replace") \ if pathlib.Path("/tmp/draft_out.txt").is_file() else "" m = re.search(r"(.*?)", raw, re.DOTALL) notes = (m.group(1).strip() if m else "") if notes: pathlib.Path("/tmp/release_notes.md").write_text(notes + "\n") print("Using AI-synthesized release notes.") else: print("::warning::No RELEASE_NOTES block parsed — keeping mechanical draft.") PYEOF # --- 3) Mint the write-token — ONLY now, after the agent has run --- - name: Mint App token (omnigent) id: app-token if: steps.guard.outputs.proceed == 'true' && steps.guard.outputs.dry_run != 'true' && vars.OMNIGENT_BOT_APP_ID != '' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ vars.OMNIGENT_BOT_APP_ID }} private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }} owner: ${{ github.repository_owner }} repositories: omnigent # Find the DRAFT release for this tag using the App token (push access) — a # read-only token can't see drafts. Match by tag_name over the release list: # GitHub's get-by-tag REST endpoint 404s on drafts (their tag isn't "real" # until published), so only a list-and-filter finds them. Sets: # is_draft — true only when a matching UNPUBLISHED draft exists (so we # never clobber notes a maintainer already published). # release_id — numeric id to edit by (editing by tag would 404 on a draft). - name: Resolve draft release id: release if: steps.guard.outputs.proceed == 'true' && steps.guard.outputs.dry_run != 'true' && steps.app-token.outputs.token != '' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} TAG: ${{ steps.guard.outputs.tag }} run: | set -euo pipefail # Read TAG via jq's `env`, not by interpolating it into the jq program — # a tag containing `"` or jq syntax would otherwise alter the filter. # (gh api's built-in --jq has no --arg; env keeps the value as data.) match="$(gh api "repos/${SOURCE_REPO}/releases" --paginate \ --jq 'map(select(.tag_name == env.TAG)) | first // empty')" is_draft=false; release_id="" if [ -n "$match" ]; then is_draft="$(printf '%s' "$match" | jq -r '.draft')" release_id="$(printf '%s' "$match" | jq -r '.id')" fi if [ "$is_draft" != "true" ]; then echo "::notice::No unpublished draft release found for ${TAG} — leaving release notes untouched (the CHANGELOG PR still runs)." fi echo "is_draft=${is_draft}" >> "$GITHUB_OUTPUT" echo "release_id=${release_id}" >> "$GITHUB_OUTPUT" echo "Draft release for ${TAG}: is_draft=${is_draft} release_id=${release_id:-}" \ | tee -a "$GITHUB_STEP_SUMMARY" # --- 4) Open/update the CHANGELOG.md PR --- - name: Open or update the CHANGELOG.md PR if: steps.guard.outputs.proceed == 'true' && steps.guard.outputs.dry_run != 'true' && steps.app-token.outputs.token != '' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} SITE_TOKEN: ${{ steps.app-token.outputs.token }} TAG: ${{ steps.guard.outputs.tag }} run: | set -euo pipefail if [ -z "$(git status --porcelain -- CHANGELOG.md)" ]; then echo "CHANGELOG.md already up to date for ${TAG} — nothing to do." \ | tee -a "$GITHUB_STEP_SUMMARY" exit 0 fi BRANCH="auto/changelog/${TAG}" git config user.name "omnigent-ci[bot]" git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com" # No credentials persisted in .git/config (the unsandboxed agent ran # earlier); push via the token URL, which GitHub masks in logs. PUSH_URL="https://x-access-token:${SITE_TOKEN}@github.com/${SOURCE_REPO}.git" git switch -C "$BRANCH" git add CHANGELOG.md git commit -m "docs(changelog): record ${TAG}" git push --force "$PUSH_URL" "$BRANCH" if [ -n "$(gh pr list --repo "$SOURCE_REPO" --head "$BRANCH" --state open --json number --jq '.[].number')" ]; then echo "CHANGELOG PR already open for ${BRANCH} — force-push updated it." exit 0 fi body="$(printf 'Records **%s** in `CHANGELOG.md`, harvested from the `## Changelog` section of each merged PR. Merge as part of cutting the release so the draft notes '"'"'Full Changelog'"'"' link resolves.\n\nGenerated by `.github/workflows/draft-release-notes.yml`.' "$TAG")" gh pr create \ --repo "$SOURCE_REPO" \ --base main \ --head "$BRANCH" \ --title "docs(changelog): record ${TAG}" \ --body "$body" # --- 5) Enrich the GitHub Release DRAFT body (only while still a draft) --- - name: Enrich the release draft body if: steps.guard.outputs.proceed == 'true' && steps.release.outputs.is_draft == 'true' env: GH_TOKEN: ${{ steps.app-token.outputs.token }} TAG: ${{ steps.guard.outputs.tag }} RELEASE_ID: ${{ steps.release.outputs.release_id }} run: | set -euo pipefail # Always end the notes with the community thanks. The AI drafter curates # freely (and can drop a hand-added line), so this is appended here rather # than via the prompt — every release, AI-drafted or mechanical fallback, # gets it. Idempotent, and placed just before the trailing "Full Changelog:" # link to match the layout of prior releases. python3 - <<'PYEOF' import pathlib NOTE = ( "### 💜 Thanks to our community\n\n" "This release was shaped by the people who filed issues, opened PRs, and " "talked through feature requests with us on our Discord! Thank you for " "building omnigent with us, keep the bug reports, ideas and contributions " "coming :)" ) path = pathlib.Path("/tmp/release_notes.md") text = path.read_text(encoding="utf-8").rstrip("\n") if "Thanks to our community" not in text: idx = text.find("\nFull Changelog:") if idx != -1: head, tail = text[:idx].rstrip("\n"), text[idx:].lstrip("\n") text = f"{head}\n\n{NOTE}\n\n{tail}" else: text = f"{text}\n\n{NOTE}" path.write_text(text + "\n", encoding="utf-8") PYEOF # github-release.yml seeds only a short placeholder body (no # auto-generated notes), so replace it wholesale with the curated notes. # Edit by release ID: a draft release can't be addressed by tag (the # get/edit-by-tag REST endpoint 404s until the release is published). gh api --method PATCH "repos/${SOURCE_REPO}/releases/${RELEASE_ID}" \ --field body=@/tmp/release_notes.md > /dev/null echo "Enriched the ${TAG} release draft with curated notes + community note." \ | tee -a "$GITHUB_STEP_SUMMARY" # ::add-mask:: redacts rendered logs, not artifact files — scrub the key # from artifacts (incl. the unscanned stderr) before upload. - name: Redact secrets from artifacts if: always() && steps.guard.outputs.proceed == 'true' && steps.creds.outputs.available == 'true' env: LLM_API_KEY: ${{ secrets.LLM_API_KEY }} run: | set -euo pipefail [ -n "${LLM_API_KEY:-}" ] || exit 0 python3 - <<'PYEOF' import os, pathlib key = os.environ.get("LLM_API_KEY", "") for f in ["draft-stderr.log", "/tmp/draft_out.txt", "/tmp/draft_prompt.txt", "/tmp/release_notes.md"]: p = pathlib.Path(f) if not p.is_file() or not key: continue t = p.read_text(encoding="utf-8", errors="replace") if key in t: p.write_text(t.replace(key, "***REDACTED***"), encoding="utf-8") print(f"redacted key from {f}") PYEOF - name: Upload logs on failure if: always() && steps.guard.outputs.proceed == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: draft-release-notes-${{ steps.guard.outputs.tag }}-${{ github.run_id }} path: | draft-stderr.log /tmp/draft_out.txt /tmp/release_notes.md /tmp/mechanical_notes.md retention-days: 7 if-no-files-found: ignore