Files
deepagents/.github/workflows/release_notes.yml
Mason Daugherty b630fb32e0 feat(infra): post a timeline comment when @release-bot draft regenerates notes (#5456)
Re-running `@release-bot draft` on a release PR now posts a short
timeline comment linking to the regenerated notes, so the refresh is
visible without re-opening the original comment.

---

Re-drafting edits the bot's curated-notes comment in place, and GitHub
surfaces comment edits quietly: no notification, no timeline entry. From
the timeline the PR looked unchanged, so a maintainer who re-ran `draft`
with steering instructions had no signal that the notes actually
regenerated.

The upsert helper now reports whether it created or edited the draft
comment, and the draft job posts the pointer only after an in-place
edit. First-time drafts (including the automatic `ready_for_review`
draft) post nothing extra — a brand-new comment already shows up in the
timeline. The notice is best-effort: if posting it fails, the draft
still succeeds and the failure lands as a workflow warning. The notice
body never mentions `@release-bot`, so it cannot re-trigger the command
flow.

Example — maintainer re-runs `draft` with steering instructions:

1. Bot edits its existing curated-notes comment in place (no timeline
entry).
2. Bot posts a new timeline comment:

> The curated release-notes comment on this PR was regenerated in place;
review the latest draft in [the original comment](#issuecomment-…).
2026-08-12 14:12:17 -07:00

481 lines
22 KiB
YAML

# Draft and apply curated release notes for any ready release-please release PR.
# The package under release is derived from the PR head ref, so every component in
# release-please-config.json is covered.
# All automation runs from a trusted `main` checkout (`trusted-source`); the ambient
# GITHUB_TOKEN is read-only for contents. Release PR content is read only through
# GitHub's API at the validated commit SHA and treated as untrusted data; it is
# never checked out or executed. Drafting is a single request to a fixed model API:
# untrusted text is never given filesystem, shell, or network tools, and only the
# selected provider key is present in that process. Repository mutations —
# curated-notes comments, PR-body updates, and a
# non-force Git Data API update to the release-please branch — are performed only by
# specific helper steps that receive a short-lived GitHub App token. (The validate job's
# permission/readiness feedback comments use the default GITHUB_TOKEN.)
name: "📝 Curate release notes"
on:
pull_request_target:
types: [ready_for_review]
issue_comment:
types: [created]
permissions:
contents: read
concurrency:
group: release-notes-${{ github.event.pull_request.number || github.event.issue.number }}
cancel-in-progress: false
jobs:
validate:
name: Validate release-notes command
# Skip the noise: run only for ready_for_review PR events, or PR comments that
# mention the bot AND come from a repo insider. The author_association filter
# stops an external drive-by mention from spawning a run (Actions-minute burn)
# and mirrors the in-script FEEDBACK_ASSOCIATIONS gate; validateTrigger still
# re-checks the exact command and write permission, so this only drops comments
# that could never have triggered an action.
if: >-
github.event_name == 'pull_request_target' ||
(github.event.issue.pull_request &&
contains(github.event.comment.body, '@release-bot') &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
issues: write
pull-requests: read
outputs:
should-run: ${{ steps.validate.outputs.should-run }}
command: ${{ steps.validate.outputs.command }}
instructions: ${{ steps.validate.outputs.instructions }}
number: ${{ steps.validate.outputs.number }}
version: ${{ steps.validate.outputs.version }}
head: ${{ steps.validate.outputs.head }}
branch: ${{ steps.validate.outputs.branch }}
steps:
- name: Checkout trusted automation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
path: trusted-source
persist-credentials: false
- name: Validate event, PR, and maintainer permission
id: validate
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
BOT_LOGIN: ${{ vars.RELEASE_BOT_LOGIN }}
BOT_ID: ${{ vars.RELEASE_BOT_ID }}
with:
script: |
const { validateTrigger } = require('./trusted-source/.github/scripts/release/release-notes.js');
const result = await validateTrigger({
github,
context,
core,
botLogin: process.env.BOT_LOGIN,
botId: process.env.BOT_ID,
});
core.setOutput('should-run', String(result.shouldRun === true));
for (const key of ['command', 'instructions', 'number', 'version', 'head', 'branch']) {
core.setOutput(key, result[key] ?? '');
}
# A transient error while validating an insider's manual @release-bot
# command would otherwise leave only a red job in the Actions tab; surface it
# on the PR so the maintainer who ran the command knows to retry. Scoped to the
# manual (issue_comment) path so a transient failure on an unrelated
# ready_for_review PR does not draw a comment, and best-effort so it can never
# mask the underlying failure that already reds the job.
- name: Comment on validation failure
if: ${{ failure() && github.event_name == 'issue_comment' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
try {
await github.rest.issues.createComment({
...context.repo,
issue_number: context.payload.issue.number,
body: 'Could not validate the `@release-bot` command because of a workflow error; see the run logs and try the command again.',
});
} catch (error) {
core.warning(`Could not post the validation-failure comment: ${error instanceof Error ? error.message : String(error)}`);
}
draft:
name: Draft curated release notes
needs: validate
if: needs.validate.outputs.should-run == 'true' && needs.validate.outputs.command == 'draft'
runs-on: ubuntu-latest
timeout-minutes: 30
environment: release-bot
permissions:
contents: read
steps:
- name: Generate release bot token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.ORG_MEMBERSHIP_APP_CLIENT_ID }}
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
- name: Checkout trusted automation
id: checkout-trusted
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
path: trusted-source
persist-credentials: false
- name: Prepare isolated drafting input
id: prepare
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ needs.validate.outputs.number }}
PR_HEAD: ${{ needs.validate.outputs.head }}
INSTRUCTIONS: ${{ needs.validate.outputs.instructions }}
with:
script: |
const { prepareDraft } = require('./trusted-source/.github/scripts/release/release-notes.js');
try {
const result = await prepareDraft({
github,
...context.repo,
number: Number(process.env.PR_NUMBER),
expectedHead: process.env.PR_HEAD,
runnerTemp: process.env.RUNNER_TEMP,
instructions: process.env.INSTRUCTIONS ?? '',
});
for (const [key, value] of Object.entries(result)) core.setOutput(key, value);
} catch (error) {
// Record the reason so the failure-comment step can surface it on the
// PR instead of the maintainer having to open the Actions logs.
core.setOutput('error', error instanceof Error ? error.message : String(error));
throw error;
}
# Pin Node so the drafting helper (which relies on global fetch and
# AbortSignal.timeout) doesn't depend on whatever the runner image
# preinstalls; matches the version the helper tests run under in ci.yml.
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: "24"
- name: Draft polished release notes without model tools
id: draft-model
continue-on-error: true
env:
# The configured model must accept an output-token limit of at least
# 32,768; lower-limit models are intentionally unsupported.
MODEL_SPEC: ${{ vars.RELEASE_BOT_MODEL }}
# Put only the configured provider's credential in this process. The
# deterministic helper sends one request to a fixed provider endpoint;
# model output is never interpreted as a tool call or URL.
MODEL_API_KEY: >-
${{ startsWith(vars.RELEASE_BOT_MODEL, 'openai:') && secrets.OPENAI_API_KEY ||
startsWith(vars.RELEASE_BOT_MODEL, 'anthropic:') && secrets.ANTHROPIC_API_KEY ||
startsWith(vars.RELEASE_BOT_MODEL, 'google_genai:') && secrets.GOOGLE_API_KEY ||
'' }}
INPUT_FILE: ${{ steps.prepare.outputs.input }}
OUTPUT_FILE: ${{ steps.prepare.outputs.output }}
# Capture the helper's stderr into a step output so its message reaches
# the PR comment. A `run:` step publishes no outputs of its own, and the
# failure-comment step can only report what it can read — so without this
# every failure here collapses into "the drafting step did not succeed",
# and the messages that name RELEASE_BOT_MODEL, the rejected model, and
# the remedy stay buried in the Actions log. The steps either side of this
# one already do the same thing via core.setOutput.
#
# Safe to publish: the helper never interpolates MODEL_API_KEY into an
# error (the key is sent only in request headers), and a provider error
# body is truncated before it is thrown.
run: |
if err="$(node ./trusted-source/.github/scripts/release/draft-release-notes.js 2>&1 >/dev/null)"; then
exit 0
fi
# Random delimiter: the message embeds a provider-supplied error body,
# so a fixed one could be closed early by the content itself.
delimiter="EOF_$(openssl rand -hex 16)"
{
printf 'error<<%s\n' "$delimiter"
printf '%s\n' "$err"
printf '%s\n' "$delimiter"
} >> "$GITHUB_OUTPUT"
# Also annotate, so the reason is visible in the run summary without
# expanding this step. Folded to one line: ::error:: takes no newlines.
printf '::error::%s\n' "$(printf '%s' "$err" | tr '\n' ' ')"
exit 1
- name: Post bot-authored curated draft
id: post
if: steps.draft-model.outcome == 'success'
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
DRAFT_STATE: ${{ steps.prepare.outputs.state }}
DRAFT_OUTPUT: ${{ steps.prepare.outputs.output }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
BOT_LOGIN: ${{ vars.RELEASE_BOT_LOGIN }}
BOT_ID: ${{ vars.RELEASE_BOT_ID }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { postDraft } = require('./trusted-source/.github/scripts/release/release-notes.js');
try {
await postDraft({
github,
...context.repo,
stateFile: process.env.DRAFT_STATE,
outputFile: process.env.DRAFT_OUTPUT,
appSlug: process.env.APP_SLUG,
login: process.env.BOT_LOGIN,
id: process.env.BOT_ID,
core,
});
} catch (error) {
core.setOutput('error', error instanceof Error ? error.message : String(error));
throw error;
}
# Runs for any non-success in prepare/agent/post, including a hard failure in
# `prepare` (which would otherwise skip a plain `success()`-gated step and
# leave the maintainer with no PR feedback). `!cancelled()` keeps it firing
# after an earlier step failed the job.
- name: Comment on drafting failure
if: ${{ !cancelled() && (steps.prepare.outcome != 'success' || steps.draft-model.outcome != 'success' || steps.post.outcome != 'success') }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ needs.validate.outputs.number }}
PR_HEAD: ${{ needs.validate.outputs.head }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
BOT_LOGIN: ${{ vars.RELEASE_BOT_LOGIN }}
BOT_ID: ${{ vars.RELEASE_BOT_ID }}
PREPARE_ERROR: ${{ steps.prepare.outputs.error }}
DRAFT_ERROR: ${{ steps.draft-model.outputs.error }}
POST_ERROR: ${{ steps.post.outputs.error }}
DRAFT_OUTCOME: ${{ steps.draft-model.outcome }}
CHECKOUT_TRUSTED_OUTCOME: ${{ steps.checkout-trusted.outcome }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const number = Number(process.env.PR_NUMBER);
// Causal order: prepare runs first, so its failure means drafting
// never ran; drafting's failure means posting never ran. Report the
// earliest step that actually failed, and fall back to the outcome
// only when no step captured a reason.
const details = process.env.PREPARE_ERROR
|| process.env.DRAFT_ERROR
|| process.env.POST_ERROR
|| (process.env.CHECKOUT_TRUSTED_OUTCOME !== 'success'
? 'Checking out the trusted automation failed; see the workflow logs.'
: `The drafting step did not succeed (outcome: ${process.env.DRAFT_OUTCOME || 'skipped'}); see the workflow logs.`);
try {
const { postDraftFailure } = require('./trusted-source/.github/scripts/release/release-notes.js');
await postDraftFailure({
github,
...context.repo,
number,
head: process.env.PR_HEAD,
appSlug: process.env.APP_SLUG,
login: process.env.BOT_LOGIN,
id: process.env.BOT_ID,
message: details,
});
} catch (error) {
// The trusted checkout can itself fail, leaving trusted-source/ (and the
// helper module) absent so the require above throws — exactly the case
// the CHECKOUT_TRUSTED_OUTCOME branch above is written to report. Fall
// back to a direct comment (no bot-identity check or per-head dedup) so
// the maintainer still gets PR feedback instead of only a red run.
core.warning(`Falling back to a direct drafting-failure comment: ${error instanceof Error ? error.message : String(error)}`);
await github.rest.issues.createComment({
...context.repo,
issue_number: number,
body: `Automatic release-note drafting failed.\n\n${details}\n\nAfter resolving the issue, a maintainer should run \`@release-bot draft\` again.`,
});
}
- name: Fail when drafting or comment publication failed
if: ${{ !cancelled() && (steps.prepare.outcome != 'success' || steps.draft-model.outcome != 'success' || steps.post.outcome != 'success') }}
run: |
echo "::error::Curated release-note drafting failed; see the PR comment and earlier step logs."
exit 1
apply:
name: Apply curated release notes
needs: validate
if: needs.validate.outputs.should-run == 'true' && needs.validate.outputs.command == 'apply'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: release-bot
permissions:
contents: read
steps:
- name: Generate release bot token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
client-id: ${{ vars.ORG_MEMBERSHIP_APP_CLIENT_ID }}
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
- name: Checkout trusted automation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
path: trusted-source
persist-credentials: false
- name: Validate override and prepare changelog/body edits
id: prepare-apply
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ needs.validate.outputs.number }}
PR_HEAD: ${{ needs.validate.outputs.head }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
BOT_LOGIN: ${{ vars.RELEASE_BOT_LOGIN }}
BOT_ID: ${{ vars.RELEASE_BOT_ID }}
APPLY_STATE: ${{ runner.temp }}/release-notes-apply.json
CHANGELOG_FILE: ${{ runner.temp }}/release-notes-changelog.md
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { prepareApply } = require('./trusted-source/.github/scripts/release/release-notes.js');
try {
await prepareApply({
github,
...context.repo,
number: Number(process.env.PR_NUMBER),
expectedHead: process.env.PR_HEAD,
changelogFile: process.env.CHANGELOG_FILE,
stateFile: process.env.APPLY_STATE,
appSlug: process.env.APP_SLUG,
login: process.env.BOT_LOGIN,
id: process.env.BOT_ID,
});
core.setOutput('state', process.env.APPLY_STATE);
core.setOutput('changelog', process.env.CHANGELOG_FILE);
} catch (error) {
core.setOutput('error', error instanceof Error ? error.message : String(error));
throw error;
}
- name: Create and publish the apply commit without rewriting history
id: commit
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
APPLY_STATE: ${{ steps.prepare-apply.outputs.state }}
CHANGELOG_FILE: ${{ steps.prepare-apply.outputs.changelog }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
BOT_LOGIN: ${{ vars.RELEASE_BOT_LOGIN }}
BOT_ID: ${{ vars.RELEASE_BOT_ID }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { createApplyCommit } = require('./trusted-source/.github/scripts/release/release-notes.js');
try {
const result = await createApplyCommit({
github,
...context.repo,
stateFile: process.env.APPLY_STATE,
changelogFile: process.env.CHANGELOG_FILE,
appSlug: process.env.APP_SLUG,
login: process.env.BOT_LOGIN,
id: process.env.BOT_ID,
});
core.setOutput('applied-head', result.appliedHead);
} catch (error) {
// Record the reason so the failure-comment step can surface it on the
// PR instead of the maintainer having to open the Actions logs.
core.setOutput('error', error instanceof Error ? error.message : String(error));
throw error;
}
- name: Update PR preview and publish applied metadata
id: publish
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
APPLY_STATE: ${{ steps.prepare-apply.outputs.state }}
APPLIED_HEAD: ${{ steps.commit.outputs.applied-head }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
BOT_LOGIN: ${{ vars.RELEASE_BOT_LOGIN }}
BOT_ID: ${{ vars.RELEASE_BOT_ID }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const { publishAppliedState } = require('./trusted-source/.github/scripts/release/release-notes.js');
try {
await publishAppliedState({
github,
...context.repo,
stateFile: process.env.APPLY_STATE,
appliedHead: process.env.APPLIED_HEAD,
appSlug: process.env.APP_SLUG,
login: process.env.BOT_LOGIN,
id: process.env.BOT_ID,
});
} catch (error) {
core.setOutput('error', error instanceof Error ? error.message : String(error));
throw error;
}
# The apply steps have no continue-on-error, so any failure reds the job;
# this mirrors the draft job's failure comment so a maintainer who ran
# `apply` sees the reason on the PR instead of only a red Actions run.
- name: Comment on apply failure
if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ needs.validate.outputs.number }}
PR_HEAD: ${{ needs.validate.outputs.head }}
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
BOT_LOGIN: ${{ vars.RELEASE_BOT_LOGIN }}
BOT_ID: ${{ vars.RELEASE_BOT_ID }}
PREPARE_ERROR: ${{ steps.prepare-apply.outputs.error }}
COMMIT_ERROR: ${{ steps.commit.outputs.error }}
PUBLISH_ERROR: ${{ steps.publish.outputs.error }}
COMMIT_OUTCOME: ${{ steps.commit.outcome }}
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const number = Number(process.env.PR_NUMBER);
// Steps run prepare-apply -> commit -> publish, each skipping the rest on
// failure, so this precedence surfaces the first (root-cause) failure.
const details = process.env.PREPARE_ERROR
|| process.env.COMMIT_ERROR
|| process.env.PUBLISH_ERROR
|| `commit=${process.env.COMMIT_OUTCOME || 'skipped'}; see the workflow logs.`;
try {
const { postApplyFailure } = require('./trusted-source/.github/scripts/release/release-notes.js');
await postApplyFailure({
github,
...context.repo,
number,
head: process.env.PR_HEAD,
appSlug: process.env.APP_SLUG,
login: process.env.BOT_LOGIN,
id: process.env.BOT_ID,
message: details,
});
} catch (error) {
// The trusted checkout can itself fail, leaving the helper module absent
// so the require above throws. Fall back to a direct comment (no
// bot-identity check or per-head dedup) so the maintainer still gets PR
// feedback instead of only a red run.
core.warning(`Falling back to a direct apply-failure comment: ${error instanceof Error ? error.message : String(error)}`);
await github.rest.issues.createComment({
...context.repo,
issue_number: number,
body: `Applying curated release notes failed.\n\n${details}`,
});
}