'use strict'; const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const PACKAGE = 'deepagents-code'; const CHANGELOG_PATH = 'libs/code/CHANGELOG.md'; const RELEASE_BRANCH = 'release-please--branches--main--components--deepagents-code'; const BYPASS_LABEL = 'release: dangerously skip curated notes'; const COMMAND_MENTION = '@dcode-release-bot'; const OVERRIDE_MARKER = 'dcode-release-notes-override'; const APPLIED_MARKER = 'dcode-release-notes-applied'; const CONTENT_START = ''; const CONTENT_END = ''; const STALE_MARKER = ''; const close = text.indexOf(closeMarker); if (close < 0) return null; const metadataText = text.slice(prefix.length, close); const metadata = {}; for (const line of metadataText.split('\n')) { const match = /^([a-z0-9-]+): (.+)$/.exec(line); if (!match || !allowedFields.has(match[1]) || metadata[match[1]] !== undefined) return null; metadata[match[1]] = match[2]; } if ([...allowedFields].some(field => metadata[field] === undefined)) return null; let remainderStart = close + closeMarker.length; if (text[remainderStart] === '\n') remainderStart += 1; return { metadata, remainder: text.slice(remainderStart) }; } function parseOverrideComment(comment) { const parsed = parseMetadata(comment.body, OVERRIDE_MARKER, OVERRIDE_FIELDS); if (!parsed || parsed.metadata.state !== 'draft') return null; const start = parsed.remainder.indexOf(CONTENT_START); const end = parsed.remainder.indexOf(CONTENT_END); if (start < 0 || end < 0 || end <= start) return null; const section = canonical(parsed.remainder.slice(start + CONTENT_START.length, end)); try { const extracted = extractVersionSection(section, parsed.metadata.version); if (canonical(extracted) !== section) return null; } catch (error) { // Fail-closed on the ONE expected throw — extractVersionSection's "exactly one // section" error on a malformed override body — by reclassifying it to null so // the gate blocks unvalidated content. Re-throw anything else (a future // refactor's TypeError, say) so a genuine regression is loud instead of // silently swallowed into a fail-closed null. if (error instanceof Error && /Expected exactly one release-notes section/.test(error.message)) { return null; } throw error; } return { comment, metadata: parsed.metadata, section }; } function parseAppliedComment(comment) { const parsed = parseMetadata(comment.body, APPLIED_MARKER, APPLIED_FIELDS); if (!parsed || parsed.metadata.state !== 'applied') return null; return { comment, metadata: parsed.metadata }; } // Trust a comment only when BOTH the login and the numeric id match the configured // bot: a reused or renamed login alone must not be able to impersonate the bot. function matchesBot(comment, login, id) { return comment.user?.login === login && Number(comment.user?.id) === Number(id); } function latest(items) { return [...items].sort((left, right) => Number(right.comment.id) - Number(left.comment.id))[0] ?? null; } function latestParsed(comments, login, id, version, parse) { return latest( comments .filter(comment => matchesBot(comment, login, id)) .map(parse) .filter(Boolean) .filter(item => item.metadata.package === PACKAGE && item.metadata.version === version), ); } function latestOverride(comments, login, id, version) { return latestParsed(comments, login, id, version, parseOverrideComment); } function latestApplied(comments, login, id, version) { return latestParsed(comments, login, id, version, parseAppliedComment); } // Distinguish "no command" from "ambiguous" (2+ commands) so the caller can stay // silent on a casual mention but explain a genuinely ambiguous one. Refusing to // guess between two commands is deliberate; the bot's own instructions mention // both `draft` and `apply`, so a quote-reply can legitimately contain two. function parseCommand(body) { const mention = escapeRegex(COMMAND_MENTION); const pattern = new RegExp(`(?:^|[^A-Za-z0-9_-])${mention}\\s+(draft|apply)\\b`, 'g'); const commands = [...normalize(body ?? '').matchAll(pattern)].map(match => match[1]); if (commands.length === 0) return { command: null, ambiguous: false }; if (commands.length > 1) return { command: null, ambiguous: true }; return { command: commands[0], ambiguous: false }; } function commandFromComment(body) { return parseCommand(body).command; } function overrideBody({ version, head, headingHash, fingerprint, section }) { return [ `', 'Review and edit the release notes between the content markers below. Keep the version heading intact.', '', CONTENT_START, canonical(section).trimEnd(), CONTENT_END, '', `When the release changes are finalized, run \`${COMMAND_MENTION} apply\`. Merge only after the curated release-notes check passes.`, '', `If new relevant entries appear after applying, run \`${COMMAND_MENTION} draft\` and then \`${COMMAND_MENTION} apply\` again.`, '', `The only bypass is the \`${BYPASS_LABEL}\` label.`, ].join('\n'); } function appliedBody({ version, sourceHead, appliedHead, fingerprint, overrideId, overrideUpdatedAt, contentHash }) { return [ `', 'Curated release notes were applied to the package changelog and release PR body.', '', `Do not add more \`${PACKAGE}\` changes before merge unless you are prepared to run \`${COMMAND_MENTION} draft\` and \`${COMMAND_MENTION} apply\` again.`, ].join('\n'); } async function listComments(github, owner, repo, number) { return github.paginate(github.rest.issues.listComments, { owner, repo, issue_number: number, per_page: 100, }); } // `create-github-app-token` derives `appSlug` from the same App authentication // used to mint the installation token. Verify that trusted output resolves to the // configured bot login and immutable user id without calling the user-only `/user` // endpoint, which rejects installation tokens. async function authenticatedBot(github, appSlug, login, id) { if (!appSlug) throw new Error('GitHub App token action did not report an app slug'); const tokenLogin = `${appSlug}[bot]`; if (tokenLogin !== login) { throw new Error(`GitHub App token was minted for ${tokenLogin}, expected ${login} (${id})`); } const { data: user } = await github.rest.users.getByUsername({ username: tokenLogin }); if (user.login !== login || Number(user.id) !== Number(id)) { throw new Error(`GitHub App bot is ${user.login} (${user.id}), expected ${login} (${id})`); } return user; } async function createComment(github, owner, repo, number, body) { return github.rest.issues.createComment({ owner, repo, issue_number: number, body }); } async function upsertOwnMarkedComment({ github, owner, repo, number, comments, login, id, marker, body }) { const existing = [...comments] .filter(comment => matchesBot(comment, login, id) && (comment.body ?? '').startsWith(`` marker // (which parseMetadata would later trust) or smuggle a second `## [` heading (which // the exactly-one-heading rule in sectionRange fails closed on). function validateDraftOutput(output) { const notes = canonical(output); if (notes.trim().length < 10) throw new Error('Drafting helper returned empty release notes'); if (notes.includes('`; const body = [marker, headline, '', remediation, '', `Details: ${message}`].join('\n'); const comments = await listComments(github, owner, repo, number); const existing = comments.find(comment => matchesBot(comment, login, id) && (comment.body ?? '').startsWith(marker)); if (!existing) await createComment(github, owner, repo, number, body); } async function postDraftFailure({ github, owner, repo, number, head, appSlug, login, id, message }) { return postFailure({ github, owner, repo, number, head, appSlug, login, id, message, markerBase: FAILURE_MARKER, headline: 'Automatic release-note drafting failed.', remediation: `Resolve the workflow failure, then have a release maintainer run \`${COMMAND_MENTION} draft\` again.`, }); } async function postApplyFailure({ github, owner, repo, number, head, appSlug, login, id, message }) { return postFailure({ github, owner, repo, number, head, appSlug, login, id, message, markerBase: APPLY_FAILURE_MARKER, headline: 'Applying curated release notes failed.', remediation: `Resolve the issue below, then run \`${COMMAND_MENTION} apply\` again (run \`${COMMAND_MENTION} draft\` first if the changelog changed).`, }); } async function prepareApply({ github, owner, repo, number, expectedHead, changelogFile, stateFile, appSlug, login, id }) { await authenticatedBot(github, appSlug, login, id); const pr = await getPr(github, owner, repo, number); if (!isReleasePr(pr) || pr.draft || pr.head.sha !== expectedHead) { throw new Error('Release PR changed before apply started; re-run the command'); } const version = releaseVersion(pr.title); const comments = await listComments(github, owner, repo, number); const override = latestOverride(comments, login, id, version); if (!override) throw new Error('No valid bot-authored curated release-note draft exists'); if (!override.comment.updated_at) throw new Error('Curated release-note draft is missing its GitHub revision'); if (!(await isDescendant(github, owner, repo, override.metadata['release-pr-head'], pr.head.sha))) { throw new Error(`Release PR was rewritten after drafting; run ${COMMAND_MENTION} draft before apply`); } const changelog = await fetchChangelog(github, owner, repo, pr.head.sha); const currentSection = extractVersionSection(changelog, version); const currentHeading = currentSection.split('\n')[0]; const overrideHeading = override.section.split('\n')[0]; if (sha256(overrideHeading) !== override.metadata['release-heading-hash']) { throw new Error('Keep the generated release version heading unchanged'); } const alreadyApplied = currentSection === override.section; if (!alreadyApplied && overrideHeading !== currentHeading) { throw new Error('Keep the generated release version heading unchanged'); } if (!alreadyApplied && changelogFingerprint(currentSection) !== override.metadata['changelog-fingerprint']) { throw new Error(`New generated release entries appeared; run ${COMMAND_MENTION} draft before apply`); } const updatedChangelog = alreadyApplied ? changelog : replaceVersionSection(changelog, version, override.section); fs.writeFileSync(changelogFile, updatedChangelog, { encoding: 'utf8', mode: 0o600 }); const body = replacePreviewSection(pr.body ?? '', version, override.section); writeJson(stateFile, { number, version, sourceHead: pr.head.sha, fingerprint: override.metadata['changelog-fingerprint'], overrideId: String(override.comment.id), overrideUpdatedAt: override.comment.updated_at, contentHash: sha256(override.section), changelogHash: exactSha256(updatedChangelog), body, originalBodyHash: exactSha256(pr.body ?? ''), alreadyApplied, }); return JSON.parse(fs.readFileSync(stateFile, 'utf8')); } async function validateApplySnapshot({ github, owner, repo, state, login, id, expectedHead, checkPrHead = true }) { const pr = await getPr(github, owner, repo, state.number); if ( !isReleasePr(pr) || pr.draft || (checkPrHead && pr.head.sha !== expectedHead) || exactSha256(pr.body ?? '') !== state.originalBodyHash ) { throw new Error('Release PR changed while apply was preparing; refusing to publish stale metadata'); } const comments = await listComments(github, owner, repo, state.number); const override = latestOverride(comments, login, id, state.version); if ( !override || String(override.comment.id) !== state.overrideId || override.comment.updated_at !== state.overrideUpdatedAt || sha256(override.section) !== state.contentHash ) { throw new Error('Curated release-note draft changed while apply was preparing; re-run apply'); } return comments; } async function validateReleaseBranchHead({ github, owner, repo, expectedHead }) { const ref = await github.rest.git.getRef({ owner, repo, ref: `heads/${RELEASE_BRANCH}` }); if (ref.data.object.sha !== expectedHead) { throw new Error('Release branch changed while apply was preparing; refusing to publish stale metadata'); } } async function createApplyCommit({ github, owner, repo, stateFile, changelogFile, appSlug, login, id }) { await authenticatedBot(github, appSlug, login, id); const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); await validateApplySnapshot({ github, owner, repo, state, login, id, expectedHead: state.sourceHead }); const changelog = fs.readFileSync(changelogFile, 'utf8'); if (exactSha256(changelog) !== state.changelogHash) { throw new Error('Prepared changelog changed before commit creation; re-run apply'); } if (state.alreadyApplied) return { appliedHead: state.sourceHead, created: false }; const parent = await github.rest.git.getCommit({ owner, repo, commit_sha: state.sourceHead }); const blob = await github.rest.git.createBlob({ owner, repo, content: changelog, encoding: 'utf-8' }); const tree = await github.rest.git.createTree({ owner, repo, base_tree: parent.data.tree.sha, tree: [{ path: CHANGELOG_PATH, mode: '100644', type: 'blob', sha: blob.data.sha }], }); const identity = { name: login, email: `${id}+${login}@users.noreply.github.com` }; const commit = await github.rest.git.createCommit({ owner, repo, message: 'chore(code): apply curated release notes', tree: tree.data.sha, parents: [state.sourceHead], author: identity, committer: identity, }); // Deliberate second snapshot re-check: re-validate immediately before the branch // mutation below to minimize the TOCTOU window, so a concurrent PR/override/head // change between building the commit and moving the ref cannot be published. This // is NOT redundant with the pre-commit check at the top of the function — do not // remove it. await validateApplySnapshot({ github, owner, repo, state, login, id, expectedHead: state.sourceHead }); await github.rest.git.updateRef({ owner, repo, ref: `heads/${RELEASE_BRANCH}`, sha: commit.data.sha, force: false, }); return { appliedHead: commit.data.sha, created: true }; } async function publishAppliedState({ github, owner, repo, stateFile, appliedHead, appSlug, login, id }) { await authenticatedBot(github, appSlug, login, id); const state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); const comments = await validateApplySnapshot({ github, owner, repo, state, login, id, expectedHead: appliedHead, checkPrHead: false, }); await validateReleaseBranchHead({ github, owner, repo, expectedHead: appliedHead }); await github.rest.pulls.update({ owner, repo, pull_number: state.number, body: state.body }); return upsertOwnMarkedComment({ github, owner, repo, number: state.number, comments, login, id, marker: APPLIED_MARKER, body: appliedBody({ version: state.version, sourceHead: state.sourceHead, appliedHead, fingerprint: state.fingerprint, overrideId: state.overrideId, overrideUpdatedAt: state.overrideUpdatedAt, contentHash: state.contentHash, }), }); } async function fetchChangelog(github, owner, repo, ref) { const response = await github.rest.repos.getContent({ owner, repo, path: CHANGELOG_PATH, ref }); if (Array.isArray(response.data) || response.data.type !== 'file' || !response.data.content) { throw new Error(`Could not read ${CHANGELOG_PATH} at ${ref}`); } return Buffer.from(response.data.content, response.data.encoding ?? 'base64').toString('utf8'); } async function isDescendant(github, owner, repo, base, head) { if (base === head) return true; const response = await github.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${base}...${head}`, }); return response.data.status === 'ahead' || response.data.status === 'identical'; } async function warnForNewEntries({ github, owner, repo, number, comments, head, fingerprint }) { const marker = `${STALE_MARKER}\nhead: ${head}\nchangelog-fingerprint: ${fingerprint}\n-->`; if (comments.some(comment => (comment.body ?? '').startsWith(marker))) return; await createComment( github, owner, repo, number, `${marker}\nNew generated release entries appeared; please re-run \`${COMMAND_MENTION} draft\` and then \`${COMMAND_MENTION} apply\`.`, ); } // Surface (logs only) bot-authored comments that carry a curated-notes marker // but fail strict parsing, so a corrupted or hand-edited draft is distinguishable // from "draft never ran". This only ever logs — it never treats a bad comment as // valid, so the gate stays fail-closed on the null the parsers return. (It can // still propagate a parser's non-expected re-throw, e.g. parseOverrideComment's; // callers already ran the same parsers via latestOverride/latestApplied, so any // such throw surfaces there first and still lands fail-closed in the check job.) function warnUnparsableMarkedComments({ core, comments, login, id }) { for (const [marker, parse] of [[OVERRIDE_MARKER, parseOverrideComment], [APPLIED_MARKER, parseAppliedComment]]) { for (const comment of comments) { const marked = matchesBot(comment, login, id) && (comment.body ?? '').startsWith(`