From 7a369af34095e27d7eb5ad4062837da7cb8f745b Mon Sep 17 00:00:00 2001 From: Lala Sabathil Date: Sun, 23 Aug 2026 21:06:20 +0200 Subject: [PATCH] ci(release): add trusted publishing tooling - add a UV-based py-cord-dev workflow for immutable prereleases - validate and attest exact wheel and sdist artifacts before publishing - move release policy, changelog, RTD, milestone, and Discord logic into tested Node tooling - add a disabled three-phase production release migration template - run release-tooling tests when helpers or release workflows change --- .github/scripts/release-tools.mjs | 1729 +++++++++++++++++++ .github/scripts/release-tools.test.mjs | 903 ++++++++++ .github/workflows/lib-checks.yml | 42 +- .github/workflows/release_dev.yml | 271 +++ .github/workflows/release_prod.yml.template | 538 ++++++ 5 files changed, 3478 insertions(+), 5 deletions(-) create mode 100644 .github/scripts/release-tools.mjs create mode 100644 .github/scripts/release-tools.test.mjs create mode 100644 .github/workflows/release_dev.yml create mode 100644 .github/workflows/release_prod.yml.template diff --git a/.github/scripts/release-tools.mjs b/.github/scripts/release-tools.mjs new file mode 100644 index 0000000000..48ef2b6991 --- /dev/null +++ b/.github/scripts/release-tools.mjs @@ -0,0 +1,1729 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { + appendFileSync, + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + statSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const VERSION_PATTERNS = Object.freeze({ + dev: /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.dev(0|[1-9][0-9]*)$/, + production: /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:rc(0|[1-9][0-9]*))?$/, +}); + +const OUTPUT_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SHA_PATTERN = /^[0-9a-f]{40}$/; +const CHANGELOG_CATEGORIES = Object.freeze(["Added", "Changed", "Fixed", "Deprecated", "Removed"]); +const READTHEDOCS_API_BASE = "https://readthedocs.org/api/v3"; +const CLI_USAGE = `Usage: node .github/scripts/release-tools.mjs [options] + +Shared release commands: + derive Validate a version and write derived release outputs + prepare-dev-source Copy tracked files and rewrite py-cord as py-cord-dev + stage-artifacts Validate and stage the exact wheel and source archive + validate-artifacts Validate exact channel-specific distribution files + check-tag Classify the remote annotated tag state + check-github-release Validate release metadata, immutability, and asset digests + check-immutable Require the repository immutable-release setting + check-pypi-unused Require an unpublished PyPI version + check-pypi-published Match published PyPI files to local artifacts + source-date-epoch Derive a reproducible-build timestamp from a commit + release-history Derive previous production tags without shell parsing + check-changelog Require a prepared production changelog entry + check-github-identity Require an exact GitHub automation identity + resolve-tag Resolve and validate an existing annotated tag + +Production migration commands: + update-changelog Generate the production changelog section and links + rtd-release Sync, wait for, and activate a Read the Docs version + notify-discord Render or send the production Discord announcement + milestone-candidates Derive exact compatible milestone titles + close-milestone Close the exact milestone for a published release +`; + +function fail(message) { + throw new Error(message); +} + +function requireString(value, label) { + if (typeof value !== "string" || value.length === 0) { + fail(`${label} must be a non-empty string`); + } + if (value.includes("\0")) { + fail(`${label} must not contain a NUL byte`); + } + return value; +} + +function requireSha(value, label = "commit") { + const sha = requireString(value, label).toLowerCase(); + if (!SHA_PATTERN.test(sha)) { + fail(`${label} must be a full 40-character lowercase Git SHA`); + } + return sha; +} + +function requireTag(value) { + const tag = requireString(value, "tag"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(tag)) { + fail(`invalid Git tag '${tag}'`); + } + return tag; +} + +export function parseReleaseVersion(channel, version) { + if (!Object.hasOwn(VERSION_PATTERNS, channel)) { + fail(`unsupported release channel '${channel}'`); + } + + const candidate = requireString(version, "version"); + const match = VERSION_PATTERNS[channel].exec(candidate); + if (!match) { + const expected = channel === "dev" ? "X.Y.Z.devN" : "X.Y.Z or X.Y.ZrcN"; + fail(`invalid ${channel} version '${candidate}'; expected canonical ${expected}`); + } + + return Object.freeze({ + channel, + version: candidate, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prereleaseNumber: match[4] === undefined ? null : Number(match[4]), + isPrerelease: channel === "dev" || match[4] !== undefined, + }); +} + +export function deriveRelease(channel, version) { + const parsed = parseReleaseVersion(channel, version); + if (channel === "dev") { + return Object.freeze({ + ...parsed, + distribution: "py-cord-dev", + normalizedDistribution: "py_cord_dev", + tag: `dev-v${version}`, + title: `Pycord Development ${version}`, + versionBranch: null, + wheelName: `py_cord_dev-${version}-py3-none-any.whl`, + sdistName: `py_cord_dev-${version}.tar.gz`, + }); + } + + return Object.freeze({ + ...parsed, + distribution: "py-cord", + normalizedDistribution: "py_cord", + tag: `v${version}`, + title: `v${version}`, + versionBranch: `v${parsed.major}.${parsed.minor}.x`, + wheelName: `py_cord-${version}-py3-none-any.whl`, + sdistName: `py_cord-${version}.tar.gz`, + }); +} + +export function milestoneTitleCandidates(version) { + const release = deriveRelease("production", version); + const candidates = [release.version, release.tag]; + if (release.prereleaseNumber !== null) { + const dotted = release.version.replace(/rc([0-9]+)$/, "rc.$1"); + candidates.push(dotted, `v${dotted}`); + } + return Object.freeze([...new Set(candidates)]); +} + +export function classifyMilestoneState(version, milestones) { + if (!Array.isArray(milestones)) { + fail("milestones must be an array"); + } + const candidates = milestoneTitleCandidates(version); + const matches = milestones.filter( + (milestone) => milestone && typeof milestone.title === "string" && candidates.includes(milestone.title), + ); + if (matches.length > 1) { + fail(`multiple milestones match ${version}: ${matches.map((item) => item.title).join(", ")}`); + } + return Object.freeze({ + state: matches.length === 0 ? "absent" : "matched", + candidates, + milestone: matches[0] ?? null, + }); +} + +export async function fetchGitHubMilestones( + repository, + token, + fetchImplementation = globalThis.fetch, +) { + const repo = requireRepository(repository); + const authToken = requireString(token, "GitHub token"); + const milestones = []; + for (let page = 1; page <= 100; page += 1) { + const response = await fetchImplementation( + `https://api.github.com/repos/${repo}/milestones?state=all&per_page=100&page=${page}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${authToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "error", + cache: "no-store", + }, + ); + if (!response.ok) { + await responseError(response, `GitHub milestone lookup for ${repo}`); + } + const pageItems = await response.json(); + if (!Array.isArray(pageItems)) { + fail("GitHub milestone response must be an array"); + } + milestones.push(...pageItems); + if (pageItems.length < 100) { + return milestones; + } + } + fail("GitHub milestone lookup exceeded 100 pages"); +} + +export async function closeReleaseMilestone( + { repository, version, token = null, dryRun = false }, + fetchImplementation = globalThis.fetch, +) { + const repo = requireRepository(repository); + deriveRelease("production", version); + const authToken = requireString(token, "GitHub token"); + const milestones = await fetchGitHubMilestones(repo, authToken, fetchImplementation); + const match = classifyMilestoneState(version, milestones); + if (match.state === "absent") { + fail(`no milestone matches release ${version}; expected one of: ${match.candidates.join(", ")}`); + } + + const milestone = match.milestone; + if (!Number.isInteger(milestone.number) || milestone.number < 1) { + fail(`milestone '${milestone.title}' has an invalid number`); + } + if (milestone.state !== "open" && milestone.state !== "closed") { + fail(`milestone '${milestone.title}' has invalid state '${milestone.state}'`); + } + const result = { + number: milestone.number, + title: milestone.title, + openIssues: Number.isInteger(milestone.open_issues) ? milestone.open_issues : null, + alreadyClosed: milestone.state === "closed", + dryRun: Boolean(dryRun), + closed: milestone.state === "closed", + }; + if (milestone.state === "closed" || dryRun) { + return Object.freeze(result); + } + + const response = await fetchImplementation( + `https://api.github.com/repos/${repo}/milestones/${milestone.number}`, + { + method: "PATCH", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + }, + body: JSON.stringify({ state: "closed" }), + redirect: "error", + }, + ); + if (!response.ok) { + await responseError(response, `closing GitHub milestone '${milestone.title}'`); + } + const updated = await response.json(); + if (updated.number !== milestone.number || updated.state !== "closed") { + fail(`GitHub did not report milestone '${milestone.title}' as closed`); + } + return Object.freeze({ ...result, closed: true }); +} + +function requireRepository(value) { + const repository = requireString(value, "repository"); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + fail(`invalid GitHub repository '${repository}'`); + } + return repository; +} + +export function buildUnreleasedChangelogBlock(branch) { + const branchName = requireString(branch, "branch"); + return [ + "## [Unreleased]", + "", + `These changes are available on the \`${branchName}\` branch, but have not yet been released.`, + "", + "### Added", + "", + "### Changed", + "", + "### Fixed", + "", + "### Deprecated", + "", + "### Removed", + "", + ].join("\n"); +} + +export function parseChangelogCategories(sectionBody) { + const categories = Object.fromEntries(CHANGELOG_CATEGORIES.map((name) => [name, []])); + let current = null; + for (const line of requireString(sectionBody || "\n", "changelog section").split(/\r?\n/)) { + const heading = /^###\s+(.+)$/.exec(line); + if (heading) { + current = Object.hasOwn(categories, heading[1].trim()) ? heading[1].trim() : null; + continue; + } + if (current !== null) { + categories[current].push(line); + } + } + return categories; +} + +function mergeChangelogCategories(destination, source) { + for (const category of CHANGELOG_CATEGORIES) { + destination[category].push(...source[category]); + } +} + +export function renderChangelogReleaseBody(categories) { + const parts = []; + for (const category of CHANGELOG_CATEGORIES) { + const lines = (categories[category] ?? []).filter((line) => line.trim() !== ""); + if (lines.length === 0) { + continue; + } + parts.push(`### ${category}`, "", ...lines, ""); + } + return parts.join("\n").replace(/\n+$/, ""); +} + +function replaceOrAppendChangelogLinks(text, version, previousTag, previousFinalTag, repository) { + const unreleasedLink = `[unreleased]: https://github.com/${repository}/compare/v${version}...HEAD`; + const baseTag = version.includes("rc") ? previousTag : previousFinalTag || previousTag; + const releaseLink = `[${version}]: https://github.com/${repository}/compare/${baseTag}...v${version}`; + + let updated = text.replace(/^\[unreleased]: .*$/m, unreleasedLink); + const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const versionLink = new RegExp(`^\\[${escapedVersion}]: .*$`, "m"); + if (versionLink.test(updated)) { + return updated.replace(versionLink, releaseLink); + } + if (/^\[unreleased]: .*$/m.test(updated)) { + return updated.replace(/^\[unreleased]: .*$/m, (match) => `${match}\n${releaseLink}`); + } + return `${updated.replace(/\n+$/, "")}\n${releaseLink}\n`; +} + +export function updateChangelogText({ + text, + version, + previousTag, + previousFinalTag = null, + branch, + repository, + date, +}) { + const contents = requireString(text, "changelog"); + deriveRelease("production", version); + const escapedHeadingVersion = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (new RegExp(`^## \\[${escapedHeadingVersion}](?:\\s|$)`, "m").test(contents)) { + fail(`changelog already contains a release section for ${version}`); + } + requireString(previousTag, "previous tag"); + if (previousFinalTag !== null) { + requireString(previousFinalTag, "previous final tag"); + } + requireRepository(repository); + if (!/^\d{4}-\d{2}-\d{2}$/.test(requireString(date, "release date"))) { + fail(`invalid release date '${date}'; expected YYYY-MM-DD`); + } + + const unreleasedHeading = /^## \[Unreleased]\s*$/m.exec(contents); + if (!unreleasedHeading) { + fail("missing '## [Unreleased]' heading in changelog"); + } + const start = unreleasedHeading.index; + const bodyStart = contents.indexOf("\n", start) + 1; + const followingHeader = /^## \[/m.exec(contents.slice(bodyStart)); + const end = followingHeader ? bodyStart + followingHeader.index : contents.length; + const unreleasedBody = contents.slice(bodyStart, end).replace(/\n+$/, ""); + const rest = contents.slice(end); + + const aggregated = parseChangelogCategories(unreleasedBody); + if (!version.includes("rc")) { + const sectionPattern = /^## \[([^\]]+)]([^\n]*)\n/gm; + const matches = [...rest.matchAll(sectionPattern)]; + const basePrefix = `${version}rc`; + let collecting = false; + for (let index = 0; index < matches.length; index += 1) { + const match = matches[index]; + const isReleaseCandidate = match[1].startsWith(basePrefix); + if (isReleaseCandidate && !collecting) { + collecting = true; + } + if (collecting && !isReleaseCandidate) { + break; + } + if (!collecting) { + continue; + } + const rcBodyStart = match.index + match[0].length; + const rcBodyEnd = index + 1 < matches.length ? matches[index + 1].index : rest.length; + mergeChangelogCategories( + aggregated, + parseChangelogCategories(rest.slice(rcBodyStart, rcBodyEnd).replace(/\n+$/, "")), + ); + } + } + + const releaseBody = renderChangelogReleaseBody(aggregated); + const releaseSection = `## [${version}] - ${date}\n${releaseBody}\n`; + let updated = `${contents.slice(0, start)}${buildUnreleasedChangelogBlock(branch)}\n${releaseSection}${rest}`; + updated = replaceOrAppendChangelogLinks( + updated, + version, + previousTag, + previousFinalTag, + repository, + ); + return updated.endsWith("\n") ? updated : `${updated}\n`; +} + +export function assertChangelogPreparedText(text, version, repository) { + const contents = requireString(text, "changelog"); + deriveRelease("production", version); + const repo = requireRepository(repository); + const escapedVersion = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const headings = contents.match(new RegExp(`^## \\[${escapedVersion}] - \\d{4}-\\d{2}-\\d{2}$`, "gm")) ?? []; + if (headings.length !== 1) { + fail(`expected exactly one dated changelog section for ${version}, found ${headings.length}`); + } + const releaseLink = new RegExp( + `^\\[${escapedVersion}]: https://github\\.com/${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/compare/.+\\.\\.\\.v${escapedVersion}$`, + "m", + ); + if (!releaseLink.test(contents)) { + fail(`changelog is missing the compare link for ${version}`); + } + const unreleasedLink = `[unreleased]: https://github.com/${repo}/compare/v${version}...HEAD`; + if (!contents.split(/\r?\n/).includes(unreleasedLink)) { + fail(`changelog Unreleased link does not start at v${version}`); + } + return Object.freeze({ version, prepared: true }); +} + +export function updateChangelogFile(options) { + const changelogPath = resolve(requireString(options.path, "changelog path")); + if (!existsSync(changelogPath) || !lstatSync(changelogPath).isFile()) { + fail(`changelog not found at '${changelogPath}'`); + } + const updated = updateChangelogText({ ...options, text: readFileSync(changelogPath, "utf8") }); + writeFileSync(changelogPath, updated, "utf8"); + return Object.freeze({ path: changelogPath, version: options.version }); +} + +export function deriveReadTheDocsRelease(version) { + const release = deriveRelease("production", version); + return Object.freeze({ + version: release.version, + docsVersion: release.isPrerelease ? release.versionBranch : release.tag, + hidden: release.isPrerelease, + }); +} + +async function responseError(response, operation) { + let detail = ""; + try { + detail = (await response.text()).slice(0, 500).replace(/[\r\n]+/g, " "); + } catch { + // The status is still sufficient when the response body cannot be read. + } + fail(`${operation} failed with HTTP ${response.status}${detail ? `: ${detail}` : ""}`); +} + +function delay(milliseconds) { + return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)); +} + +export async function manageReadTheDocsRelease( + { + project = "pycord", + version, + token = null, + sync = false, + dryRun = false, + attempts = 12, + retryDelayMs = 5_000, + }, + dependencies = {}, +) { + const projectSlug = requireString(project, "Read the Docs project"); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(projectSlug)) { + fail(`invalid Read the Docs project '${projectSlug}'`); + } + const plan = deriveReadTheDocsRelease(version); + const result = Object.freeze({ project: projectSlug, ...plan, sync: Boolean(sync) }); + if (dryRun) { + return result; + } + const authToken = requireString(token, "Read the Docs token"); + if (!Number.isInteger(attempts) || attempts < 1 || attempts > 60) { + fail("Read the Docs attempts must be an integer from 1 to 60"); + } + if (!Number.isInteger(retryDelayMs) || retryDelayMs < 0 || retryDelayMs > 60_000) { + fail("Read the Docs retry delay must be an integer from 0 to 60000 milliseconds"); + } + const fetchImplementation = dependencies.fetchImplementation ?? globalThis.fetch; + const delayImplementation = dependencies.delayImplementation ?? delay; + const headers = { Authorization: `Token ${authToken}`, "Content-Type": "application/json" }; + const projectUrl = `${READTHEDOCS_API_BASE}/projects/${encodeURIComponent(projectSlug)}`; + + if (sync) { + const syncResponse = await fetchImplementation(`${projectUrl}/sync-versions/`, { + method: "POST", + headers, + body: "{}", + }); + if (!syncResponse.ok) { + await responseError(syncResponse, `Read the Docs sync for ${projectSlug}`); + } + } + + const versionUrl = `${projectUrl}/versions/${encodeURIComponent(plan.docsVersion)}/`; + if (sync) { + let available = false; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const response = await fetchImplementation(versionUrl, { method: "GET", headers }); + if (response.ok) { + available = true; + break; + } + if (response.status !== 404) { + await responseError(response, `Read the Docs version lookup for ${plan.docsVersion}`); + } + if (attempt < attempts) { + await delayImplementation(retryDelayMs); + } + } + if (!available) { + fail(`Read the Docs version '${plan.docsVersion}' did not appear after ${attempts} attempts`); + } + } + + const activationResponse = await fetchImplementation(versionUrl, { + method: "PATCH", + headers, + body: JSON.stringify({ active: true, hidden: plan.hidden }), + }); + if (!activationResponse.ok) { + await responseError(activationResponse, `Read the Docs activation for ${plan.docsVersion}`); + } + return result; +} + +export function buildDiscordReleaseMessage({ version, previousTag, previousFinalTag = null, repository }) { + const release = deriveRelease("production", version); + const previous = requireString(previousTag, "previous tag"); + const previousFinal = previousFinalTag ? requireString(previousFinalTag, "previous final tag") : previous; + const repo = requireRepository(repository); + const majorMinor = `${release.major}.${release.minor}`; + const docsUrl = `https://docs.pycord.dev/en/v${version}/changelog.html`; + const baseCompare = release.isPrerelease ? previous : previousFinal; + const compareUrl = `https://github.com/${repo}/compare/${baseCompare}...v${version}`; + const releaseUrl = `https://github.com/${repo}/releases/tag/v${version}`; + const pypiUrl = `https://pypi.org/project/py-cord/${version}/`; + + if (release.isPrerelease) { + return ( + `## <:pycord:1063211537008955495> Pycord v${version} Release Candidate (${majorMinor}) is available!\n\n` + + "@here\n\n" + + "This is a pre-release (release candidate) for testing and feedback.\n\n" + + `You can view the changelog here: <${docsUrl}>\n\n` + + `Check out the [GitHub changelog](<${compareUrl}>), [GitHub release page](<${releaseUrl}>), and [PyPI release page](<${pypiUrl}>).\n\n` + + `You can install this version by running the following command:\n\`\`\`sh\npip install -U py-cord==${version}\n\`\`\`\n\n` + + "Please try it out and let us know your feedback or any issues!" + ); + } + + return ( + `## <:pycord:1063211537008955495> Pycord v${version} is out!\n\n` + + "@everyone\n\n" + + `You can view the changelog here: <${docsUrl}>\n\n` + + `Feel free to take a look at the [GitHub changelog](<${compareUrl}>), [GitHub release page](<${releaseUrl}>) and the [PyPI release page](<${pypiUrl}>).\n\n` + + `You can install this version by running the following command:\n\`\`\`sh\npip install -U py-cord==${version}\n\`\`\`` + ); +} + +export function buildDiscordReleasePayload(options) { + return Object.freeze({ + content: buildDiscordReleaseMessage(options), + allowed_mentions: { parse: ["everyone", "roles"] }, + }); +} + +export async function sendDiscordReleaseNotification(options, fetchImplementation = globalThis.fetch) { + const payload = buildDiscordReleasePayload(options); + if (options.dryRun) { + return Object.freeze({ payload, sent: false }); + } + const webhook = new URL(requireString(options.webhookUrl, "Discord webhook URL")); + if ( + webhook.protocol !== "https:" || + !["discord.com", "discordapp.com"].includes(webhook.hostname) || + !webhook.pathname.startsWith("/api/webhooks/") + ) { + fail("Discord webhook URL must be an HTTPS discord.com API webhook URL"); + } + const response = await fetchImplementation(webhook, { + method: "POST", + headers: { + Accept: "*/*", + "Content-Type": "application/json", + "User-Agent": "pycord-release-bot/1.0 (+https://github.com/Pycord-Development/pycord)", + }, + body: JSON.stringify(payload), + redirect: "error", + }); + if (!response.ok) { + await responseError(response, "Discord release notification"); + } + return Object.freeze({ payload, sent: true }); +} + +function outputValue(value) { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +export function formatGitHubOutputs(outputs, delimiterFactory = () => `ghadelimiter_${randomUUID()}`) { + if (!outputs || typeof outputs !== "object" || Array.isArray(outputs)) { + fail("GitHub outputs must be an object"); + } + + let result = ""; + for (const [name, rawValue] of Object.entries(outputs)) { + if (!OUTPUT_NAME_PATTERN.test(name)) { + fail(`invalid GitHub output name '${name}'`); + } + const value = outputValue(rawValue); + if (value.includes("\0")) { + fail(`GitHub output '${name}' must not contain a NUL byte`); + } + + let delimiter; + do { + delimiter = requireString(delimiterFactory(), "GitHub output delimiter"); + } while (value.split(/\r?\n/).includes(delimiter)); + result += `${name}<<${delimiter}\n${value}\n${delimiter}\n`; + } + return result; +} + +export function writeGitHubOutputs(outputPath, outputs) { + const destination = requireString(outputPath, "GitHub output path"); + appendFileSync(destination, formatGitHubOutputs(outputs), { encoding: "utf8" }); +} + +function isInsideOrEqual(root, candidate) { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel)); +} + +function validateTrackedPath(file) { + const candidate = requireString(file, "tracked path"); + if ( + candidate.startsWith("/") || + candidate.startsWith("\\") || + /^[A-Za-z]:[\\/]/.test(candidate) + ) { + fail(`tracked path '${candidate}' must be relative`); + } + const parts = candidate.split(/[\\/]/); + if (parts.some((part) => part === "" || part === "." || part === "..")) { + fail(`tracked path '${candidate}' contains an unsafe component`); + } + return parts; +} + +export function rewriteProjectNameText(contents) { + const text = requireString(contents, "pyproject contents"); + const lines = text.split(/(?<=\n)/); + let inProject = false; + let projectTables = 0; + let assignments = 0; + + const rewritten = lines.map((line) => { + const bareLine = line.replace(/[\r\n]+$/, ""); + const table = /^\s*\[([^\]]+)]\s*(?:#.*)?$/.exec(bareLine); + if (table) { + inProject = table[1] === "project"; + if (inProject) { + projectTables += 1; + } + return line; + } + + if (!inProject || !/^\s*name\s*=/.test(bareLine)) { + return line; + } + + assignments += 1; + const assignment = /^(\s*name\s*=\s*)(["'])([^"']*)(\2)(\s*(?:#.*)?)(\r?\n)?$/.exec(line); + if (!assignment) { + fail("[project].name must be a simple quoted string"); + } + if (assignment[3] !== "py-cord") { + fail(`[project].name must be exactly 'py-cord', found '${assignment[3]}'`); + } + return `${assignment[1]}${assignment[2]}py-cord-dev${assignment[4]}${assignment[5]}${assignment[6] ?? ""}`; + }); + + if (projectTables !== 1) { + fail(`expected exactly one [project] table, found ${projectTables}`); + } + if (assignments !== 1) { + fail(`expected exactly one [project].name assignment, found ${assignments}`); + } + return rewritten.join(""); +} + +export function listTrackedFiles(sourceDirectory) { + const source = realpathSync(requireString(sourceDirectory, "source directory")); + const output = execFileSync("git", ["-C", source, "ls-files", "-z", "--cached"], { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + windowsHide: true, + }); + return output.split("\0").filter(Boolean); +} + +export function parseSourceDateEpoch(value) { + const epoch = requireString(value, "source date epoch").trim(); + if (!/^(0|[1-9][0-9]*)$/.test(epoch)) { + fail(`invalid source date epoch '${epoch}'`); + } + return epoch; +} + +export function deriveSourceDateEpoch(repositoryDirectory, commit) { + const repository = realpathSync(requireString(repositoryDirectory, "repository directory")); + const commitSha = requireSha(commit, "source commit"); + const output = execFileSync("git", ["-C", repository, "show", "-s", "--format=%ct", commitSha], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + windowsHide: true, + }); + return parseSourceDateEpoch(output); +} + +export function prepareDevSource(sourceDirectory, destinationDirectory, options = {}) { + const source = realpathSync(requireString(sourceDirectory, "source directory")); + const destinationInput = resolve(requireString(destinationDirectory, "destination directory")); + if (existsSync(destinationInput)) { + fail(`destination '${destinationInput}' already exists`); + } + + const destinationParent = realpathSync(dirname(destinationInput)); + const destination = join(destinationParent, basename(destinationInput)); + if (isInsideOrEqual(source, destination) || isInsideOrEqual(destination, source)) { + fail("source and destination must not overlap"); + } + + const trackedFiles = options.trackedFiles ?? listTrackedFiles(source); + if (!Array.isArray(trackedFiles) || trackedFiles.length === 0) { + fail("tracked file list must be a non-empty array"); + } + + const validated = []; + const seen = new Set(); + for (const trackedFile of trackedFiles) { + const parts = validateTrackedPath(trackedFile); + const normalized = parts.join("/"); + if (seen.has(normalized)) { + fail(`duplicate tracked path '${normalized}'`); + } + seen.add(normalized); + + const sourcePath = resolve(source, ...parts); + if (!isInsideOrEqual(source, sourcePath)) { + fail(`tracked path '${trackedFile}' escapes the source directory`); + } + const info = lstatSync(sourcePath); + if (info.isSymbolicLink()) { + fail(`tracked path '${trackedFile}' is a symbolic link`); + } + if (!info.isFile()) { + fail(`tracked path '${trackedFile}' is not a regular file`); + } + const realSourcePath = realpathSync(sourcePath); + if (!isInsideOrEqual(source, realSourcePath)) { + fail(`tracked path '${trackedFile}' resolves outside the source directory`); + } + + const destinationPath = resolve(destination, ...parts); + if (!isInsideOrEqual(destination, destinationPath)) { + fail(`tracked path '${trackedFile}' escapes the destination directory`); + } + validated.push({ normalized, sourcePath: realSourcePath, destinationPath }); + } + + const pyproject = validated.find((file) => file.normalized === "pyproject.toml"); + if (!pyproject) { + fail("pyproject.toml must be Git-tracked"); + } + const rewrittenPyproject = rewriteProjectNameText(readFileSync(pyproject.sourcePath, "utf8")); + + mkdirSync(destination); + for (const file of validated) { + mkdirSync(dirname(file.destinationPath), { recursive: true }); + copyFileSync(file.sourcePath, file.destinationPath); + } + writeFileSync(pyproject.destinationPath, rewrittenPyproject, "utf8"); + + return Object.freeze({ source, destination, fileCount: validated.length }); +} + +export function sha256File(filePath) { + const file = resolve(requireString(filePath, "artifact path")); + const info = lstatSync(file); + if (!info.isFile() || info.isSymbolicLink()) { + fail(`artifact '${file}' must be a regular file`); + } + return `sha256:${createHash("sha256").update(readFileSync(file)).digest("hex")}`; +} + +export function validateArtifacts(channel, version, distDirectory) { + const release = deriveRelease(channel, version); + const distDir = realpathSync(requireString(distDirectory, "distribution directory")); + const entries = readdirSync(distDir, { withFileTypes: true }); + const names = entries.map((entry) => entry.name).sort(); + const expectedNames = [release.sdistName, release.wheelName].sort(); + + if (entries.some((entry) => !entry.isFile() || entry.isSymbolicLink())) { + fail("distribution directory must contain regular files only"); + } + if (names.length !== expectedNames.length || names.some((name, index) => name !== expectedNames[index])) { + fail(`expected exactly ${expectedNames.join(" and ")}; found ${names.join(", ") || "nothing"}`); + } + + const wheelPath = join(distDir, release.wheelName); + const sdistPath = join(distDir, release.sdistName); + return Object.freeze({ + distDir, + wheelName: release.wheelName, + wheelPath, + wheelDigest: sha256File(wheelPath), + wheelSize: statSync(wheelPath).size, + sdistName: release.sdistName, + sdistPath, + sdistDigest: sha256File(sdistPath), + sdistSize: statSync(sdistPath).size, + }); +} + +export function stageArtifacts(channel, version, sourceDirectory, destinationDirectory) { + const sourceArtifacts = validateArtifacts(channel, version, sourceDirectory); + const destination = resolve(requireString(destinationDirectory, "artifact staging directory")); + if (existsSync(destination)) { + fail(`artifact staging directory '${destination}' already exists`); + } + const destinationParent = realpathSync(dirname(destination)); + const canonicalDestination = join(destinationParent, basename(destination)); + if (isInsideOrEqual(sourceArtifacts.distDir, canonicalDestination)) { + fail("artifact staging directory must not be inside the build distribution directory"); + } + + mkdirSync(canonicalDestination); + copyFileSync(sourceArtifacts.wheelPath, join(canonicalDestination, sourceArtifacts.wheelName)); + copyFileSync(sourceArtifacts.sdistPath, join(canonicalDestination, sourceArtifacts.sdistName)); + const staged = validateArtifacts(channel, version, canonicalDestination); + if (staged.wheelDigest !== sourceArtifacts.wheelDigest || staged.sdistDigest !== sourceArtifacts.sdistDigest) { + fail("staged artifact digests do not match the build outputs"); + } + return staged; +} + +export function parseRemoteTagOutput(output, tag) { + const expectedRef = `refs/tags/${requireTag(tag)}`; + const records = requireString(output || "\n", "git ls-remote output") + .trim() + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const match = /^([0-9a-f]{40})\s+(.+)$/.exec(line); + if (!match) { + fail(`malformed git ls-remote line '${line}'`); + } + return { sha: match[1], ref: match[2] }; + }) + .filter((record) => record.ref === expectedRef || record.ref === `${expectedRef}^{}`); + return records; +} + +export function resolveAnnotatedTagState(tag, records) { + const tagName = requireTag(tag); + if (!Array.isArray(records)) { + fail("tag records must be an array"); + } + const expectedRef = `refs/tags/${tagName}`; + const direct = records.filter((record) => record.ref === expectedRef); + const peeled = records.filter((record) => record.ref === `${expectedRef}^{}`); + if (direct.length === 0 && peeled.length === 0) { + return Object.freeze({ state: "absent", commit: null }); + } + if (direct.length !== 1 || peeled.length !== 1) { + fail(`tag '${tagName}' must exist exactly once as an annotated tag`); + } + return Object.freeze({ state: "annotated", commit: requireSha(peeled[0].sha, "tag commit") }); +} + +export function resolveRemoteAnnotatedTag(repositoryDirectory, remote, tag) { + const repository = realpathSync(requireString(repositoryDirectory, "repository directory")); + const remoteName = requireString(remote, "Git remote"); + if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) { + fail(`invalid Git remote '${remoteName}'`); + } + const tagName = requireTag(tag); + const output = execFileSync( + "git", + ["-C", repository, "ls-remote", "--tags", remoteName, `refs/tags/${tagName}`, `refs/tags/${tagName}^{}`], + { encoding: "utf8", maxBuffer: 1024 * 1024, windowsHide: true }, + ); + const state = resolveAnnotatedTagState( + tagName, + output.trim() ? parseRemoteTagOutput(output, tagName) : [], + ); + if (state.state !== "annotated") { + fail(`required annotated tag '${tagName}' does not exist`); + } + return state; +} + +function compareParsedVersions(left, right) { + for (const field of ["major", "minor", "patch"]) { + if (left[field] !== right[field]) { + return left[field] - right[field]; + } + } + if (left.prereleaseNumber === null && right.prereleaseNumber !== null) { + return 1; + } + if (left.prereleaseNumber !== null && right.prereleaseNumber === null) { + return -1; + } + return (left.prereleaseNumber ?? 0) - (right.prereleaseNumber ?? 0); +} + +export function deriveReleaseHistory(version, tags) { + const current = parseReleaseVersion("production", version); + if (!Array.isArray(tags)) { + fail("production tags must be an array"); + } + const parsedTags = []; + for (const rawTag of tags) { + if (typeof rawTag !== "string" || !rawTag.startsWith("v")) { + continue; + } + try { + const parsed = parseReleaseVersion("production", rawTag.slice(1)); + if (compareParsedVersions(parsed, current) < 0) { + parsedTags.push({ tag: rawTag, parsed }); + } + } catch { + // Historical noncanonical tags are intentionally ignored. + } + } + parsedTags.sort((left, right) => compareParsedVersions(right.parsed, left.parsed)); + const previous = parsedTags[0]; + const previousFinal = parsedTags.find((entry) => entry.parsed.prereleaseNumber === null); + if (!previous || !previousFinal) { + fail(`could not determine previous production tags for ${version}`); + } + return Object.freeze({ previousTag: previous.tag, previousFinalTag: previousFinal.tag }); +} + +export function readReleaseHistory(repositoryDirectory, version) { + const repository = realpathSync(requireString(repositoryDirectory, "repository directory")); + const output = execFileSync("git", ["-C", repository, "tag", "--merged", "HEAD", "--list", "v*"], { + encoding: "utf8", + maxBuffer: 4 * 1024 * 1024, + windowsHide: true, + }); + return deriveReleaseHistory(version, output.split(/\r?\n/).filter(Boolean)); +} + +export function classifyTagState(tag, expectedCommit, records) { + const tagName = requireTag(tag); + const expected = requireSha(expectedCommit, "expected tag commit"); + if (!Array.isArray(records)) { + fail("tag records must be an array"); + } + if (records.length === 0) { + return Object.freeze({ state: "absent", createTag: true, commit: null, reason: null }); + } + + const expectedRef = `refs/tags/${tagName}`; + const direct = records.filter((record) => record.ref === expectedRef); + const peeled = records.filter((record) => record.ref === `${expectedRef}^{}`); + if (direct.length !== 1 || peeled.length > 1) { + return Object.freeze({ state: "conflicting", createTag: false, commit: null, reason: "ambiguous tag refs" }); + } + if (peeled.length === 0) { + return Object.freeze({ + state: "conflicting", + createTag: false, + commit: direct[0].sha, + reason: "existing tag is lightweight; an annotated tag is required", + }); + } + if (peeled[0].sha !== expected) { + return Object.freeze({ + state: "conflicting", + createTag: false, + commit: peeled[0].sha, + reason: `existing tag targets ${peeled[0].sha}, expected ${expected}`, + }); + } + return Object.freeze({ state: "reusable", createTag: false, commit: expected, reason: null }); +} + +export function readRemoteTagState(repositoryDirectory, remote, tag, expectedCommit) { + const repository = realpathSync(requireString(repositoryDirectory, "repository directory")); + const remoteName = requireString(remote, "Git remote"); + if (!/^[A-Za-z0-9._-]+$/.test(remoteName)) { + fail(`invalid Git remote '${remoteName}'`); + } + const tagName = requireTag(tag); + const output = execFileSync( + "git", + ["-C", repository, "ls-remote", "--tags", remoteName, `refs/tags/${tagName}`, `refs/tags/${tagName}^{}`], + { encoding: "utf8", maxBuffer: 1024 * 1024, windowsHide: true }, + ); + return classifyTagState(tagName, expectedCommit, output.trim() ? parseRemoteTagOutput(output, tagName) : []); +} + +function normalizeReleasePayload(payload) { + if (payload === null) { + return null; + } + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + fail("release state must be a JSON object or null"); + } + return { + tagName: payload.tag_name ?? payload.tagName, + title: payload.name, + isDraft: payload.draft ?? payload.isDraft, + isImmutable: payload.immutable ?? payload.isImmutable, + isPrerelease: payload.prerelease ?? payload.isPrerelease, + targetCommitish: payload.target_commitish ?? payload.targetCommitish, + assets: payload.assets, + }; +} + +export function classifyReleaseState(payload, expectedRelease, artifacts) { + const release = normalizeReleasePayload(payload); + if (release === null) { + return Object.freeze({ + state: "absent", + createRelease: true, + resumeDraft: false, + reuseRelease: false, + wheelMissing: true, + sdistMissing: true, + }); + } + if (expectedRelease.tagState !== undefined && expectedRelease.tagState !== "reusable") { + fail(`an existing release requires a reusable annotated tag, found '${expectedRelease.tagState}'`); + } + + const expectedCommit = requireSha(expectedRelease.commit, "expected release commit"); + const required = { + tagName: expectedRelease.tag, + title: expectedRelease.title, + isPrerelease: expectedRelease.isPrerelease ?? true, + targetCommitish: expectedCommit, + }; + for (const [field, value] of Object.entries(required)) { + if (release[field] !== value) { + fail(`release ${field} is ${JSON.stringify(release[field])}, expected ${JSON.stringify(value)}`); + } + } + if (!Array.isArray(release.assets)) { + fail("release assets must be an array"); + } + + const expectedAssets = new Map([ + [artifacts.wheelName, { digest: artifacts.wheelDigest, size: artifacts.wheelSize }], + [artifacts.sdistName, { digest: artifacts.sdistDigest, size: artifacts.sdistSize }], + ]); + const seen = new Set(); + for (const asset of release.assets) { + if (!asset || typeof asset.name !== "string") { + fail("release contains an asset without a valid name"); + } + if (seen.has(asset.name)) { + fail(`release contains duplicate asset '${asset.name}'`); + } + seen.add(asset.name); + const expected = expectedAssets.get(asset.name); + if (!expected) { + fail(`release contains unexpected asset '${asset.name}'`); + } + if (asset.digest !== expected.digest) { + fail(`release asset '${asset.name}' has digest ${asset.digest ?? "missing"}, expected ${expected.digest}`); + } + if (asset.size !== expected.size) { + fail(`release asset '${asset.name}' has size ${asset.size}, expected ${expected.size}`); + } + } + + const wheelMissing = !seen.has(artifacts.wheelName); + const sdistMissing = !seen.has(artifacts.sdistName); + if (release.isDraft) { + if (release.isImmutable !== false) { + fail("a draft release must not be immutable"); + } + return Object.freeze({ + state: "draft", + createRelease: false, + resumeDraft: true, + reuseRelease: false, + wheelMissing, + sdistMissing, + }); + } + + if (release.isImmutable !== true) { + fail("a published release must be immutable"); + } + if (wheelMissing || sdistMissing) { + fail("a published release is missing an expected asset"); + } + return Object.freeze({ + state: "published", + createRelease: false, + resumeDraft: false, + reuseRelease: true, + wheelMissing: false, + sdistMissing: false, + }); +} + +export async function fetchGitHubReleaseState( + repository, + tag, + token, + fetchImplementation = globalThis.fetch, +) { + const repo = requireRepository(repository); + const tagName = requireString(tag, "release tag"); + if (!/^[A-Za-z0-9._-]+$/.test(tagName)) { + fail(`invalid release tag '${tagName}'`); + } + const authToken = requireString(token, "GitHub token"); + const response = await fetchImplementation( + `https://api.github.com/repos/${repo}/releases/tags/${encodeURIComponent(tagName)}`, + { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${authToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "error", + cache: "no-store", + }, + ); + if (response.status === 404) { + return null; + } + if (!response.ok) { + await responseError(response, `GitHub release lookup for ${tagName}`); + } + return response.json(); +} + +export function assertImmutableReleaseSetting(payload) { + if (!payload || typeof payload !== "object" || payload.enabled !== true) { + fail("GitHub immutable releases must be enabled before running a release"); + } + return Object.freeze({ enabled: true, enforcedByOwner: payload.enforced_by_owner === true }); +} + +export async function assertPypiVersionUnused(project, version, fetchImplementation = globalThis.fetch) { + const projectName = requireString(project, "PyPI project"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(projectName)) { + fail(`invalid PyPI project '${projectName}'`); + } + requireString(version, "PyPI version"); + if (typeof fetchImplementation !== "function") { + fail("a Fetch API implementation is required"); + } + + const url = `https://pypi.org/pypi/${encodeURIComponent(projectName)}/${encodeURIComponent(version)}/json`; + const response = await fetchImplementation(url, { + headers: { Accept: "application/json" }, + redirect: "error", + cache: "no-store", + }); + if (response.status === 404) { + return Object.freeze({ project: projectName, version, unused: true }); + } + if (response.status === 200) { + fail(`${projectName} ${version} already exists on PyPI`); + } + fail(`PyPI version check failed with HTTP ${response.status}`); +} + +export async function assertPypiVersionPublished( + project, + version, + artifacts, + fetchImplementation = globalThis.fetch, +) { + const projectName = requireString(project, "PyPI project"); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(projectName)) { + fail(`invalid PyPI project '${projectName}'`); + } + requireString(version, "PyPI version"); + if (typeof fetchImplementation !== "function") { + fail("a Fetch API implementation is required"); + } + if (!artifacts || typeof artifacts !== "object") { + fail("validated local artifacts are required"); + } + + const url = `https://pypi.org/pypi/${encodeURIComponent(projectName)}/${encodeURIComponent(version)}/json`; + const response = await fetchImplementation(url, { + headers: { Accept: "application/json" }, + redirect: "error", + cache: "no-store", + }); + if (response.status === 404) { + fail(`${projectName} ${version} is not published on PyPI`); + } + if (!response.ok) { + await responseError(response, "PyPI published-version check"); + } + const payload = await response.json(); + if (!payload || !Array.isArray(payload.urls)) { + fail("PyPI published-version response is missing distribution files"); + } + + const expectedFiles = new Map([ + [artifacts.wheelName, { digest: artifacts.wheelDigest, size: artifacts.wheelSize }], + [artifacts.sdistName, { digest: artifacts.sdistDigest, size: artifacts.sdistSize }], + ]); + const seen = new Set(); + for (const file of payload.urls) { + if (!file || typeof file.filename !== "string") { + fail("PyPI published-version response contains a file without a valid filename"); + } + if (seen.has(file.filename)) { + fail(`PyPI contains duplicate distribution '${file.filename}'`); + } + seen.add(file.filename); + const expected = expectedFiles.get(file.filename); + if (!expected) { + fail(`PyPI contains unexpected distribution '${file.filename}'`); + } + const digest = file.digests?.sha256; + if (`sha256:${digest}` !== expected.digest) { + fail(`PyPI distribution '${file.filename}' does not match the local SHA-256 digest`); + } + if (file.size !== expected.size) { + fail(`PyPI distribution '${file.filename}' has size ${file.size}, expected ${expected.size}`); + } + } + for (const filename of expectedFiles.keys()) { + if (!seen.has(filename)) { + fail(`PyPI is missing expected distribution '${filename}'`); + } + } + return Object.freeze({ project: projectName, version, published: true }); +} + +export async function assertGitHubIdentity( + expectedLogin, + token, + fetchImplementation = globalThis.fetch, +) { + const expected = requireString(expectedLogin, "expected GitHub login"); + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/.test(expected)) { + fail(`invalid expected GitHub login '${expected}'`); + } + const authToken = requireString(token, "GitHub token"); + if (typeof fetchImplementation !== "function") { + fail("a Fetch API implementation is required"); + } + const response = await fetchImplementation("https://api.github.com/user", { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${authToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + redirect: "error", + cache: "no-store", + }); + if (!response.ok) { + await responseError(response, "GitHub identity check"); + } + const payload = await response.json(); + if (!payload || payload.login !== expected) { + fail(`GitHub token authenticates as '${payload?.login ?? "unknown"}', expected '${expected}'`); + } + return Object.freeze({ login: expected, verified: true }); +} + +function parseArguments(argv) { + if (argv.length === 0) { + fail("a release-tools subcommand is required"); + } + const command = argv[0]; + const options = {}; + for (let index = 1; index < argv.length; index += 2) { + const name = argv[index]; + const value = argv[index + 1]; + if (!name?.startsWith("--") || value === undefined) { + fail(`malformed argument list near '${name ?? "end of input"}'`); + } + const key = name.slice(2); + if (!/^[a-z][a-z0-9-]*$/.test(key) || Object.hasOwn(options, key)) { + fail(`invalid or duplicate option '${name}'`); + } + options[key] = value; + } + return { command, options }; +} + +function requireOptions(options, required, optional = []) { + const allowed = new Set([...required, ...optional]); + for (const name of Object.keys(options)) { + if (!allowed.has(name)) { + fail(`unexpected option '--${name}'`); + } + } + for (const name of required) { + if (!Object.hasOwn(options, name)) { + fail(`missing required option '--${name}'`); + } + } +} + +function parseBooleanOption(value, label) { + if (value === undefined) { + return false; + } + if (value !== "true" && value !== "false") { + fail(`${label} must be 'true' or 'false'`); + } + return value === "true"; +} + +function parseIntegerOption(value, label, fallback) { + if (value === undefined) { + return fallback; + } + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + fail(`${label} must be a non-negative integer`); + } + return Number(value); +} + +function emitResult(result, outputs, outputPath) { + if (outputPath) { + writeGitHubOutputs(outputPath, outputs); + } + process.stdout.write(`${JSON.stringify(result)}\n`); +} + +async function runCli(argv) { + const { command, options } = parseArguments(argv); + const defaultOutput = options["github-output"] ?? process.env.GITHUB_OUTPUT; + + if (command === "help" || command === "--help") { + requireOptions(options, []); + process.stdout.write(CLI_USAGE); + return; + } + + if (command === "derive") { + requireOptions(options, ["channel", "version"], ["github-output"]); + const release = deriveRelease(options.channel, options.version); + emitResult( + release, + { + version: release.version, + tag: release.tag, + title: release.title, + distribution: release.distribution, + normalized_distribution: release.normalizedDistribution, + prerelease: release.isPrerelease, + latest: !release.isPrerelease, + version_branch: release.versionBranch, + wheel_name: release.wheelName, + sdist_name: release.sdistName, + }, + defaultOutput, + ); + return; + } + + if (command === "update-changelog") { + requireOptions( + options, + ["path", "version", "previous-tag", "branch", "repository"], + ["previous-final-tag", "date"], + ); + const result = updateChangelogFile({ + path: options.path, + version: options.version, + previousTag: options["previous-tag"], + previousFinalTag: options["previous-final-tag"] ?? null, + branch: options.branch, + repository: options.repository, + date: options.date ?? new Date().toISOString().slice(0, 10), + }); + emitResult(result, {}, null); + return; + } + + if (command === "release-history") { + requireOptions(options, ["repository", "version"], ["github-output"]); + const history = readReleaseHistory(options.repository, options.version); + emitResult( + history, + { previous_tag: history.previousTag, previous_final_tag: history.previousFinalTag }, + defaultOutput, + ); + return; + } + + if (command === "check-changelog") { + requireOptions(options, ["path", "version", "repository"]); + const changelogPath = resolve(requireString(options.path, "changelog path")); + if (!existsSync(changelogPath) || !lstatSync(changelogPath).isFile()) { + fail(`changelog not found at '${changelogPath}'`); + } + emitResult( + assertChangelogPreparedText( + readFileSync(changelogPath, "utf8"), + options.version, + options.repository, + ), + {}, + null, + ); + return; + } + + if (command === "rtd-release") { + requireOptions( + options, + ["version"], + ["project", "sync", "dry-run", "token-env", "attempts", "retry-delay-ms"], + ); + const tokenEnvironment = options["token-env"] ?? "READTHEDOCS_TOKEN"; + if (!OUTPUT_NAME_PATTERN.test(tokenEnvironment)) { + fail(`invalid token environment variable '${tokenEnvironment}'`); + } + const result = await manageReadTheDocsRelease({ + project: options.project ?? "pycord", + version: options.version, + token: process.env[tokenEnvironment] ?? null, + sync: parseBooleanOption(options.sync, "--sync"), + dryRun: parseBooleanOption(options["dry-run"], "--dry-run"), + attempts: parseIntegerOption(options.attempts, "--attempts", 12), + retryDelayMs: parseIntegerOption(options["retry-delay-ms"], "--retry-delay-ms", 5_000), + }); + emitResult(result, {}, null); + return; + } + + if (command === "notify-discord") { + requireOptions( + options, + ["version", "previous-tag", "repository"], + ["previous-final-tag", "dry-run", "webhook-env"], + ); + const webhookEnvironment = options["webhook-env"] ?? "DISCORD_WEBHOOK_URL"; + if (!OUTPUT_NAME_PATTERN.test(webhookEnvironment)) { + fail(`invalid webhook environment variable '${webhookEnvironment}'`); + } + const result = await sendDiscordReleaseNotification({ + version: options.version, + previousTag: options["previous-tag"], + previousFinalTag: options["previous-final-tag"] ?? null, + repository: options.repository, + webhookUrl: process.env[webhookEnvironment] ?? null, + dryRun: parseBooleanOption(options["dry-run"], "--dry-run"), + }); + emitResult(result, {}, null); + return; + } + + if (command === "prepare-dev-source") { + requireOptions(options, ["source", "destination"], ["github-output"]); + const prepared = prepareDevSource(options.source, options.destination); + emitResult(prepared, { source_dir: prepared.destination }, defaultOutput); + return; + } + + if (command === "source-date-epoch") { + requireOptions(options, ["repository", "commit"], ["github-output"]); + const epoch = deriveSourceDateEpoch(options.repository, options.commit); + emitResult({ sourceDateEpoch: epoch }, { source_date_epoch: epoch }, defaultOutput); + return; + } + + if (command === "validate-artifacts") { + requireOptions(options, ["channel", "version", "dist-dir"], ["github-output"]); + const artifacts = validateArtifacts(options.channel, options.version, options["dist-dir"]); + emitResult( + artifacts, + { + dist_dir: artifacts.distDir, + wheel_name: artifacts.wheelName, + wheel_path: artifacts.wheelPath, + wheel_digest: artifacts.wheelDigest, + sdist_name: artifacts.sdistName, + sdist_path: artifacts.sdistPath, + sdist_digest: artifacts.sdistDigest, + }, + defaultOutput, + ); + return; + } + + if (command === "stage-artifacts") { + requireOptions( + options, + ["channel", "version", "source-dir", "destination"], + ["github-output"], + ); + const artifacts = stageArtifacts( + options.channel, + options.version, + options["source-dir"], + options.destination, + ); + emitResult( + artifacts, + { + dist_dir: artifacts.distDir, + wheel_name: artifacts.wheelName, + wheel_path: artifacts.wheelPath, + wheel_digest: artifacts.wheelDigest, + sdist_name: artifacts.sdistName, + sdist_path: artifacts.sdistPath, + sdist_digest: artifacts.sdistDigest, + }, + defaultOutput, + ); + return; + } + + if (command === "check-tag") { + requireOptions(options, ["repository", "remote", "tag", "expected-commit"], ["github-output"]); + const state = readRemoteTagState( + options.repository, + options.remote, + options.tag, + options["expected-commit"], + ); + if (state.state === "conflicting") { + fail(`tag '${options.tag}' conflicts: ${state.reason}`); + } + emitResult(state, { tag_state: state.state, create_tag: state.createTag }, defaultOutput); + return; + } + + if (command === "resolve-tag") { + requireOptions(options, ["repository", "remote", "tag"], ["github-output"]); + const state = resolveRemoteAnnotatedTag(options.repository, options.remote, options.tag); + emitResult( + state, + { tag_state: "reusable", tag_commit: state.commit }, + defaultOutput, + ); + return; + } + + if (command === "check-release") { + requireOptions( + options, + ["channel", "version", "expected-commit", "dist-dir", "state-file"], + ["github-output"], + ); + const derived = deriveRelease(options.channel, options.version); + const artifacts = validateArtifacts(options.channel, options.version, options["dist-dir"]); + const payload = JSON.parse(readFileSync(options["state-file"], "utf8")); + const state = classifyReleaseState( + payload, + { + tag: derived.tag, + title: derived.title, + commit: options["expected-commit"], + isPrerelease: derived.isPrerelease, + }, + artifacts, + ); + emitResult( + state, + { + release_state: state.state, + create_release: state.createRelease, + resume_draft: state.resumeDraft, + reuse_release: state.reuseRelease, + wheel_missing: state.wheelMissing, + sdist_missing: state.sdistMissing, + }, + defaultOutput, + ); + return; + } + + if (command === "check-github-release") { + requireOptions( + options, + ["channel", "version", "expected-commit", "dist-dir", "repository", "tag-state"], + ["github-output", "token-env"], + ); + const tokenEnvironment = options["token-env"] ?? "GITHUB_TOKEN"; + if (!OUTPUT_NAME_PATTERN.test(tokenEnvironment)) { + fail(`invalid token environment variable '${tokenEnvironment}'`); + } + const derived = deriveRelease(options.channel, options.version); + const artifacts = validateArtifacts(options.channel, options.version, options["dist-dir"]); + const payload = await fetchGitHubReleaseState( + options.repository, + derived.tag, + process.env[tokenEnvironment] ?? null, + ); + const state = classifyReleaseState( + payload, + { + tag: derived.tag, + title: derived.title, + commit: options["expected-commit"], + tagState: options["tag-state"], + isPrerelease: derived.isPrerelease, + }, + artifacts, + ); + emitResult( + state, + { + release_state: state.state, + create_release: state.createRelease, + resume_draft: state.resumeDraft, + reuse_release: state.reuseRelease, + wheel_missing: state.wheelMissing, + sdist_missing: state.sdistMissing, + }, + defaultOutput, + ); + return; + } + + if (command === "check-immutable") { + requireOptions(options, ["state-file"]); + const payload = JSON.parse(readFileSync(options["state-file"], "utf8")); + emitResult(assertImmutableReleaseSetting(payload), {}, null); + return; + } + + if (command === "check-pypi-unused") { + requireOptions(options, ["project", "version"]); + emitResult(await assertPypiVersionUnused(options.project, options.version), {}, null); + return; + } + + if (command === "check-pypi-published") { + requireOptions(options, ["project", "version", "channel", "dist-dir"]); + const artifacts = validateArtifacts(options.channel, options.version, options["dist-dir"]); + emitResult( + await assertPypiVersionPublished(options.project, options.version, artifacts), + {}, + null, + ); + return; + } + + if (command === "check-github-identity") { + requireOptions(options, ["expected-login"], ["token-env"]); + const tokenEnvironment = options["token-env"] ?? "GITHUB_TOKEN"; + if (!OUTPUT_NAME_PATTERN.test(tokenEnvironment)) { + fail(`invalid token environment variable '${tokenEnvironment}'`); + } + emitResult( + await assertGitHubIdentity( + options["expected-login"], + process.env[tokenEnvironment] ?? null, + ), + {}, + null, + ); + return; + } + + if (command === "milestone-candidates") { + requireOptions(options, ["version"], ["github-output"]); + const candidates = milestoneTitleCandidates(options.version); + emitResult({ candidates }, { milestone_candidates: candidates }, defaultOutput); + return; + } + + if (command === "close-milestone") { + requireOptions(options, ["repository", "version"], ["dry-run", "token-env"]); + const tokenEnvironment = options["token-env"] ?? "GITHUB_TOKEN"; + if (!OUTPUT_NAME_PATTERN.test(tokenEnvironment)) { + fail(`invalid token environment variable '${tokenEnvironment}'`); + } + const result = await closeReleaseMilestone({ + repository: options.repository, + version: options.version, + token: process.env[tokenEnvironment] ?? null, + dryRun: parseBooleanOption(options["dry-run"], "--dry-run"), + }); + emitResult(result, {}, null); + return; + } + + fail(`unknown release-tools subcommand '${command}'`); +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : null; +if (invokedPath && invokedPath === fileURLToPath(import.meta.url)) { + try { + await runCli(process.argv.slice(2)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + process.stderr.write(`release-tools: ${message.replace(/[\r\n]+/g, " ")}\n`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/release-tools.test.mjs b/.github/scripts/release-tools.test.mjs new file mode 100644 index 0000000000..5b9e8468e4 --- /dev/null +++ b/.github/scripts/release-tools.test.mjs @@ -0,0 +1,903 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "node:test"; + +import { + assertChangelogPreparedText, + assertGitHubIdentity, + assertImmutableReleaseSetting, + assertPypiVersionPublished, + assertPypiVersionUnused, + buildDiscordReleaseMessage, + buildDiscordReleasePayload, + buildUnreleasedChangelogBlock, + closeReleaseMilestone, + classifyMilestoneState, + classifyReleaseState, + classifyTagState, + deriveReadTheDocsRelease, + deriveRelease, + deriveReleaseHistory, + fetchGitHubReleaseState, + formatGitHubOutputs, + manageReadTheDocsRelease, + milestoneTitleCandidates, + parseChangelogCategories, + parseReleaseVersion, + parseSourceDateEpoch, + prepareDevSource, + renderChangelogReleaseBody, + resolveAnnotatedTagState, + rewriteProjectNameText, + sendDiscordReleaseNotification, + stageArtifacts, + updateChangelogText, + validateArtifacts, + writeGitHubOutputs, +} from "./release-tools.mjs"; + +const temporaryDirectories = []; + +function temporaryDirectory() { + const directory = mkdtempSync(join(tmpdir(), "pycord-release-tools-")); + temporaryDirectories.push(directory); + return directory; +} + +function createSourceFixture() { + const root = temporaryDirectory(); + writeFileSync( + join(root, "pyproject.toml"), + '[build-system]\nrequires = ["hatchling"]\n\n[project]\nname = "py-cord"\ndynamic = ["version"]\n', + ); + mkdirSync(join(root, "discord")); + writeFileSync(join(root, "discord", "__init__.py"), "__version__ = 'test'\n"); + writeFileSync(join(root, "untracked.txt"), "must not be copied\n"); + return root; +} + +function createArtifacts(channel = "dev", version = "2.8.2.dev1") { + const directory = temporaryDirectory(); + const release = deriveRelease(channel, version); + writeFileSync(join(directory, release.wheelName), "wheel-content"); + writeFileSync(join(directory, release.sdistName), "sdist-content"); + return validateArtifacts(channel, version, directory); +} + +function releaseAsset(name, digest, size) { + return { name, digest, size }; +} + +function matchingReleasePayload(artifacts, overrides = {}) { + return { + tag_name: "dev-v2.8.2.dev1", + name: "Pycord Development 2.8.2.dev1", + draft: false, + immutable: true, + prerelease: true, + target_commitish: "a".repeat(40), + assets: [ + releaseAsset(artifacts.wheelName, artifacts.wheelDigest, artifacts.wheelSize), + releaseAsset(artifacts.sdistName, artifacts.sdistDigest, artifacts.sdistSize), + ], + ...overrides, + }; +} + +function response(status, body = "") { + return { + ok: status >= 200 && status < 300, + status, + async text() { + return body; + }, + }; +} + +function jsonResponse(status, payload) { + return { + ...response(status, JSON.stringify(payload)), + async json() { + return payload; + }, + }; +} + +afterEach(() => { + while (temporaryDirectories.length > 0) { + rmSync(temporaryDirectories.pop(), { recursive: true, force: true }); + } +}); + +test("accepts canonical development versions", () => { + for (const version of ["0.0.0.dev0", "2.8.2.dev1", "10.20.30.dev456"]) { + assert.equal(parseReleaseVersion("dev", version).version, version); + } +}); + +test("accepts canonical production final and release candidate versions", () => { + assert.equal(parseReleaseVersion("production", "2.9.0").isPrerelease, false); + assert.equal(parseReleaseVersion("production", "2.9.0rc1").isPrerelease, true); +}); + +test("rejects malformed, noncanonical, and hostile versions", () => { + const rejected = [ + "2.8.2", + "2.8.2rc1", + "2.8.2.dev", + "2.8.2.dev01", + "02.8.2.dev1", + "2.8.2.dev1 ", + " 2.8.2.dev1", + "2.8.2.dev1+local", + "2.8.2.dev1; echo bad", + "$(whoami)", + "2.8.2-dev1", + ]; + for (const version of rejected) { + assert.throws(() => parseReleaseVersion("dev", version)); + } + for (const version of ["2.9.0.dev1", "2.9.0rc.1", "2.9", "v2.9.0", "2.9.0+local"]) { + assert.throws(() => parseReleaseVersion("production", version)); + } +}); + +test("derives development release values", () => { + assert.deepEqual( + deriveRelease("dev", "2.8.2.dev1"), + { + channel: "dev", + version: "2.8.2.dev1", + major: 2, + minor: 8, + patch: 2, + prereleaseNumber: 1, + isPrerelease: true, + distribution: "py-cord-dev", + normalizedDistribution: "py_cord_dev", + tag: "dev-v2.8.2.dev1", + title: "Pycord Development 2.8.2.dev1", + versionBranch: null, + wheelName: "py_cord_dev-2.8.2.dev1-py3-none-any.whl", + sdistName: "py_cord_dev-2.8.2.dev1.tar.gz", + }, + ); +}); + +test("derives future production branch, tag, artifacts, and prerelease state", () => { + const release = deriveRelease("production", "2.9.0rc1"); + assert.equal(release.tag, "v2.9.0rc1"); + assert.equal(release.title, "v2.9.0rc1"); + assert.equal(release.versionBranch, "v2.9.x"); + assert.equal(release.wheelName, "py_cord-2.9.0rc1-py3-none-any.whl"); + assert.equal(release.isPrerelease, true); +}); + +test("rewrites only the project name", () => { + const before = + '[project]\nname = "py-cord" # distribution\ndescription = "py-cord remains here"\n\n[project.urls]\nname = "unchanged"\n'; + const after = rewriteProjectNameText(before); + assert.equal( + after, + '[project]\nname = "py-cord-dev" # distribution\ndescription = "py-cord remains here"\n\n[project.urls]\nname = "unchanged"\n', + ); +}); + +test("rejects missing, duplicate, or unexpected project names", () => { + assert.throws(() => rewriteProjectNameText('[project]\ndescription = "missing"\n'), /assignment, found 0/); + assert.throws( + () => rewriteProjectNameText('[project]\nname = "py-cord"\nname = "py-cord"\n'), + /assignment, found 2/, + ); + assert.throws(() => rewriteProjectNameText('[project]\nname = "py-cord-dev"\n'), /must be exactly/); + assert.throws( + () => rewriteProjectNameText('[project]\nname = "py-cord"\n\n[project]\nname = "py-cord"\n'), + /one \[project] table/, + ); +}); + +test("copies only declared tracked files into an isolated development source", () => { + const source = createSourceFixture(); + const destination = join(temporaryDirectory(), "prepared"); + const result = prepareDevSource(source, destination, { + trackedFiles: ["pyproject.toml", "discord/__init__.py"], + }); + assert.equal(result.fileCount, 2); + assert.match(readFileSync(join(destination, "pyproject.toml"), "utf8"), /name = "py-cord-dev"/); + assert.equal(readFileSync(join(destination, "discord", "__init__.py"), "utf8"), "__version__ = 'test'\n"); + assert.throws(() => readFileSync(join(destination, "untracked.txt"))); + assert.match(readFileSync(join(source, "pyproject.toml"), "utf8"), /name = "py-cord"/); +}); + +test("accepts only canonical source date epochs", () => { + assert.equal(parseSourceDateEpoch("1724414400\n"), "1724414400"); + for (const value of ["", "-1", "01", "1.5", "$(date)"]) { + assert.throws(() => parseSourceDateEpoch(value)); + } +}); + +test("rejects source and destination overlap", () => { + const source = createSourceFixture(); + assert.throws( + () => prepareDevSource(source, join(source, "prepared"), { trackedFiles: ["pyproject.toml"] }), + /must not overlap/, + ); +}); + +test("rejects tracked path traversal", () => { + const source = createSourceFixture(); + const destination = join(temporaryDirectory(), "prepared"); + assert.throws( + () => prepareDevSource(source, destination, { trackedFiles: ["pyproject.toml", "../secret.txt"] }), + /unsafe component/, + ); +}); + +test("rejects tracked paths that escape through a directory symlink", () => { + const source = createSourceFixture(); + const outside = temporaryDirectory(); + writeFileSync(join(outside, "secret.txt"), "secret"); + const link = join(source, "linked"); + symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir"); + const destination = join(temporaryDirectory(), "prepared"); + assert.throws( + () => prepareDevSource(source, destination, { trackedFiles: ["pyproject.toml", "linked/secret.txt"] }), + /resolves outside/, + ); +}); + +test("accepts exactly the expected wheel and sdist", () => { + const artifacts = createArtifacts(); + assert.equal(artifacts.wheelName, "py_cord_dev-2.8.2.dev1-py3-none-any.whl"); + assert.equal(artifacts.sdistName, "py_cord_dev-2.8.2.dev1.tar.gz"); + assert.match(artifacts.wheelDigest, /^sha256:[0-9a-f]{64}$/); +}); + +test("stages only validated artifacts and preserves their digests", () => { + const source = createArtifacts(); + const destination = join(temporaryDirectory(), "dist-dev"); + const staged = stageArtifacts("dev", "2.8.2.dev1", source.distDir, destination); + assert.equal(staged.wheelDigest, source.wheelDigest); + assert.equal(staged.sdistDigest, source.sdistDigest); + assert.throws( + () => stageArtifacts("dev", "2.8.2.dev1", source.distDir, destination), + /already exists/, + ); +}); + +test("rejects missing, wrong, duplicate-equivalent, and extra distributions", () => { + const directory = temporaryDirectory(); + writeFileSync(join(directory, "py_cord_dev-2.8.2.dev1-py3-none-any.whl"), "wheel"); + assert.throws(() => validateArtifacts("dev", "2.8.2.dev1", directory), /expected exactly/); + writeFileSync(join(directory, "py_cord_dev-2.8.2.dev1.tar.gz"), "sdist"); + writeFileSync(join(directory, "py_cord_dev-2.8.2.dev1.zip"), "extra"); + assert.throws(() => validateArtifacts("dev", "2.8.2.dev1", directory), /expected exactly/); + rmSync(join(directory, "py_cord_dev-2.8.2.dev1.zip")); + rmSync(join(directory, "py_cord_dev-2.8.2.dev1.tar.gz")); + writeFileSync(join(directory, "py_cord_dev-2.8.2.dev2.tar.gz"), "wrong version"); + assert.throws(() => validateArtifacts("dev", "2.8.2.dev1", directory), /expected exactly/); +}); + +test("classifies absent, reusable annotated, lightweight, and conflicting tags", () => { + const tag = "dev-v2.8.2.dev1"; + const expected = "a".repeat(40); + assert.equal(classifyTagState(tag, expected, []).state, "absent"); + assert.equal( + classifyTagState(tag, expected, [ + { ref: `refs/tags/${tag}`, sha: "b".repeat(40) }, + { ref: `refs/tags/${tag}^{}`, sha: expected }, + ]).state, + "reusable", + ); + assert.match( + classifyTagState(tag, expected, [{ ref: `refs/tags/${tag}`, sha: expected }]).reason, + /lightweight/, + ); + assert.match( + classifyTagState(tag, expected, [ + { ref: `refs/tags/${tag}`, sha: "b".repeat(40) }, + { ref: `refs/tags/${tag}^{}`, sha: "c".repeat(40) }, + ]).reason, + /targets/, + ); + assert.throws(() => classifyTagState("dev-v2.8.2.dev1;bad", expected, []), /invalid Git tag/); +}); + +test("resolves only a single annotated tag and exposes its peeled commit", () => { + const tag = "v2.9.0"; + const commit = "a".repeat(40); + assert.equal(resolveAnnotatedTagState(tag, []).state, "absent"); + assert.deepEqual( + resolveAnnotatedTagState(tag, [ + { ref: `refs/tags/${tag}`, sha: "b".repeat(40) }, + { ref: `refs/tags/${tag}^{}`, sha: commit }, + ]), + { state: "annotated", commit }, + ); + assert.throws( + () => resolveAnnotatedTagState(tag, [{ ref: `refs/tags/${tag}`, sha: commit }]), + /annotated tag/, + ); +}); + +test("derives ordered production history while ignoring noncanonical tags", () => { + assert.deepEqual( + deriveReleaseHistory("2.9.0", ["v2.8.1", "v2.9.0rc1", "v2.9.0rc2", "v2.9.0rc.3", "dev-v2.9.0.dev1"]), + { previousTag: "v2.9.0rc2", previousFinalTag: "v2.8.1" }, + ); + assert.deepEqual( + deriveReleaseHistory("2.9.0rc1", ["v2.8.0", "v2.8.1", "v2.9.0"]), + { previousTag: "v2.8.1", previousFinalTag: "v2.8.1" }, + ); + assert.throws(() => deriveReleaseHistory("1.0.0", ["v1.0.0rc1"]), /previous production tags/); +}); + +test("classifies an absent release", () => { + const artifacts = createArtifacts(); + const state = classifyReleaseState( + null, + { tag: "dev-v2.8.2.dev1", title: "Pycord Development 2.8.2.dev1", commit: "a".repeat(40) }, + artifacts, + ); + assert.equal(state.state, "absent"); + assert.equal(state.createRelease, true); +}); + +test("classifies a matching resumable draft and identifies missing assets", () => { + const artifacts = createArtifacts(); + const payload = matchingReleasePayload(artifacts, { + draft: true, + immutable: false, + assets: [releaseAsset(artifacts.wheelName, artifacts.wheelDigest, artifacts.wheelSize)], + }); + const state = classifyReleaseState( + payload, + { tag: payload.tag_name, title: payload.name, commit: payload.target_commitish }, + artifacts, + ); + assert.equal(state.state, "draft"); + assert.equal(state.wheelMissing, false); + assert.equal(state.sdistMissing, true); +}); + +test("classifies a matching immutable published release", () => { + const artifacts = createArtifacts(); + const payload = matchingReleasePayload(artifacts); + const state = classifyReleaseState( + payload, + { tag: payload.tag_name, title: payload.name, commit: payload.target_commitish }, + artifacts, + ); + assert.equal(state.state, "published"); + assert.equal(state.reuseRelease, true); +}); + +test("classifies final production releases as non-prereleases", () => { + const artifacts = createArtifacts("production", "2.9.0"); + const payload = { + tag_name: "v2.9.0", + name: "v2.9.0", + draft: false, + immutable: true, + prerelease: false, + target_commitish: "a".repeat(40), + assets: [ + releaseAsset(artifacts.wheelName, artifacts.wheelDigest, artifacts.wheelSize), + releaseAsset(artifacts.sdistName, artifacts.sdistDigest, artifacts.sdistSize), + ], + }; + assert.equal( + classifyReleaseState( + payload, + { + tag: payload.tag_name, + title: payload.name, + commit: payload.target_commitish, + isPrerelease: false, + }, + artifacts, + ).state, + "published", + ); + assert.throws( + () => + classifyReleaseState( + { ...payload, prerelease: true }, + { + tag: payload.tag_name, + title: payload.name, + commit: payload.target_commitish, + isPrerelease: false, + }, + artifacts, + ), + /isPrerelease/, + ); +}); + +test("rejects mismatched release metadata, mutable publication, assets, and digests", () => { + const artifacts = createArtifacts(); + const expected = { + tag: "dev-v2.8.2.dev1", + title: "Pycord Development 2.8.2.dev1", + commit: "a".repeat(40), + }; + assert.throws( + () => classifyReleaseState(matchingReleasePayload(artifacts, { name: "wrong" }), expected, artifacts), + /title/, + ); + assert.throws( + () => classifyReleaseState(matchingReleasePayload(artifacts, { immutable: false }), expected, artifacts), + /must be immutable/, + ); + assert.throws( + () => + classifyReleaseState( + matchingReleasePayload(artifacts, { + assets: [ + releaseAsset(artifacts.wheelName, artifacts.wheelDigest, artifacts.wheelSize), + releaseAsset("unexpected.txt", `sha256:${"0".repeat(64)}`, 1), + ], + }), + expected, + artifacts, + ), + /unexpected asset/, + ); + assert.throws( + () => + classifyReleaseState( + matchingReleasePayload(artifacts, { + assets: [ + releaseAsset(artifacts.wheelName, `sha256:${"0".repeat(64)}`, artifacts.wheelSize), + releaseAsset(artifacts.sdistName, artifacts.sdistDigest, artifacts.sdistSize), + ], + }), + expected, + artifacts, + ), + /digest/, + ); +}); + +test("requires an existing release to have a reusable annotated tag", () => { + const artifacts = createArtifacts(); + const payload = matchingReleasePayload(artifacts); + assert.throws( + () => + classifyReleaseState( + payload, + { + tag: payload.tag_name, + title: payload.name, + commit: payload.target_commitish, + tagState: "absent", + }, + artifacts, + ), + /requires a reusable annotated tag/, + ); +}); + +test("fetches GitHub release state and treats only 404 as absent", async () => { + let request; + const payload = { tag_name: "dev-v2.8.2.dev1" }; + const found = await fetchGitHubReleaseState( + "Pycord-Development/pycord", + "dev-v2.8.2.dev1", + "token", + async (url, options) => { + request = { url, options }; + return { ...response(200), async json() { return payload; } }; + }, + ); + assert.deepEqual(found, payload); + assert.match(request.url, /releases\/tags\/dev-v2\.8\.2\.dev1$/); + assert.equal(request.options.headers.Authorization, "Bearer token"); + assert.equal( + await fetchGitHubReleaseState( + "Pycord-Development/pycord", + "dev-v2.8.2.dev1", + "token", + async () => response(404), + ), + null, + ); + await assert.rejects( + fetchGitHubReleaseState( + "Pycord-Development/pycord", + "dev-v2.8.2.dev1", + "token", + async () => response(500), + ), + /HTTP 500/, + ); +}); + +test("requires immutable releases to be enabled", () => { + assert.deepEqual(assertImmutableReleaseSetting({ enabled: true, enforced_by_owner: false }), { + enabled: true, + enforcedByOwner: false, + }); + assert.throws(() => assertImmutableReleaseSetting({ enabled: false }), /must be enabled/); +}); + +test("treats only a PyPI 404 as an unused version", async () => { + assert.equal( + (await assertPypiVersionUnused("py-cord-dev", "2.8.2.dev1", async () => response(404))).unused, + true, + ); + await assert.rejects( + assertPypiVersionUnused("py-cord-dev", "2.8.2.dev1", async () => response(200)), + /already exists/, + ); + await assert.rejects( + assertPypiVersionUnused("py-cord-dev", "2.8.2.dev1", async () => response(503)), + /HTTP 503/, + ); +}); + +test("matches a published PyPI version to the exact local artifacts", async () => { + const artifacts = createArtifacts("production", "2.9.0"); + const urls = [artifacts.wheelName, artifacts.sdistName].map((filename) => { + const wheel = filename === artifacts.wheelName; + return { + filename, + size: wheel ? artifacts.wheelSize : artifacts.sdistSize, + digests: { sha256: (wheel ? artifacts.wheelDigest : artifacts.sdistDigest).slice(7) }, + }; + }); + const result = await assertPypiVersionPublished( + "py-cord", + "2.9.0", + artifacts, + async () => jsonResponse(200, { urls }), + ); + assert.equal(result.published, true); + await assert.rejects( + assertPypiVersionPublished( + "py-cord", + "2.9.0", + artifacts, + async () => jsonResponse(200, { urls: urls.slice(0, 1) }), + ), + /missing expected distribution/, + ); + await assert.rejects( + assertPypiVersionPublished( + "py-cord", + "2.9.0", + artifacts, + async () => jsonResponse(200, { urls: [{ ...urls[0], digests: { sha256: "0".repeat(64) } }, urls[1]] }), + ), + /SHA-256/, + ); + await assert.rejects( + assertPypiVersionPublished("py-cord", "2.9.0", artifacts, async () => response(404)), + /not published/, + ); +}); + +test("requires the exact GitHub automation identity", async () => { + let request; + const result = await assertGitHubIdentity("NyuwBot", "token", async (url, options) => { + request = { url, options }; + return jsonResponse(200, { login: "NyuwBot" }); + }); + assert.deepEqual(result, { login: "NyuwBot", verified: true }); + assert.equal(request.options.headers.Authorization, "Bearer token"); + await assert.rejects( + assertGitHubIdentity("NyuwBot", "token", async () => jsonResponse(200, { login: "someone-else" })), + /expected 'NyuwBot'/, + ); + await assert.rejects( + assertGitHubIdentity("NyuwBot", "token", async () => response(401)), + /HTTP 401/, + ); +}); + +test("generates future production milestone candidates and rejects ambiguity", () => { + assert.deepEqual(milestoneTitleCandidates("2.9.0"), ["2.9.0", "v2.9.0"]); + assert.deepEqual(milestoneTitleCandidates("2.9.0rc1"), [ + "2.9.0rc1", + "v2.9.0rc1", + "2.9.0rc.1", + "v2.9.0rc.1", + ]); + assert.equal(classifyMilestoneState("2.9.0", [{ title: "2.9.0" }]).state, "matched"); + assert.throws( + () => classifyMilestoneState("2.9.0", [{ title: "2.9.0" }, { title: "v2.9.0" }]), + /multiple milestones/, + ); +}); + +test("dry-runs an exact release milestone close without mutation", async () => { + const calls = []; + const result = await closeReleaseMilestone( + { + repository: "Pycord-Development/pycord", + version: "2.9.0rc1", + token: "token", + dryRun: true, + }, + async (url, options) => { + calls.push({ url, options }); + return jsonResponse(200, [ + { number: 14, title: "2.9.0rc1", state: "open", open_issues: 35 }, + { number: 13, title: "2.9.0", state: "open", open_issues: 0 }, + ]); + }, + ); + assert.deepEqual(result, { + number: 14, + title: "2.9.0rc1", + openIssues: 35, + alreadyClosed: false, + dryRun: true, + closed: false, + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].options.method, undefined); +}); + +test("closes the exact release milestone and verifies the response", async () => { + const calls = []; + const result = await closeReleaseMilestone( + { + repository: "Pycord-Development/pycord", + version: "2.9.0rc1", + token: "token", + }, + async (url, options) => { + calls.push({ url, options }); + if (options.method === "PATCH") { + return jsonResponse(200, { number: 9, title: "v2.9.0rc.1", state: "closed" }); + } + return jsonResponse(200, [ + { number: 9, title: "v2.9.0rc.1", state: "open", open_issues: 2 }, + ]); + }, + ); + assert.equal(result.closed, true); + assert.equal(result.title, "v2.9.0rc.1"); + assert.equal(calls.length, 2); + assert.equal(calls[1].options.method, "PATCH"); + assert.deepEqual(JSON.parse(calls[1].options.body), { state: "closed" }); +}); + +test("milestone closing is idempotent and rejects absent or ambiguous matches", async () => { + const closed = await closeReleaseMilestone( + { repository: "Pycord-Development/pycord", version: "2.9.0", token: "token" }, + async () => jsonResponse(200, [{ number: 13, title: "2.9.0", state: "closed", open_issues: 0 }]), + ); + assert.equal(closed.alreadyClosed, true); + await assert.rejects( + closeReleaseMilestone( + { repository: "Pycord-Development/pycord", version: "2.9.0", token: "token" }, + async () => jsonResponse(200, []), + ), + /no milestone matches/, + ); + await assert.rejects( + closeReleaseMilestone( + { repository: "Pycord-Development/pycord", version: "2.9.0", token: "token" }, + async () => + jsonResponse(200, [ + { number: 13, title: "2.9.0", state: "open", open_issues: 0 }, + { number: 99, title: "v2.9.0", state: "open", open_issues: 0 }, + ]), + ), + /multiple milestones/, + ); +}); + +test("serializes safe multiline GitHub outputs", () => { + const formatted = formatGitHubOutputs( + { version: "2.8.2.dev1", notes: "line one\nline two" }, + (() => { + let index = 0; + return () => `delimiter_${index++}`; + })(), + ); + assert.equal( + formatted, + "version< formatGitHubOutputs({ "bad-name": "value" }), /invalid GitHub output name/); +}); + +test("appends GitHub outputs without truncating earlier values", () => { + const directory = temporaryDirectory(); + const output = join(directory, "output.txt"); + writeFileSync(output, "existing=value\n"); + writeGitHubOutputs(output, { next: "value" }); + assert.match(readFileSync(output, "utf8"), /^existing=value\nnext< { + const block = buildUnreleasedChangelogBlock("master"); + assert.match(block, /`master` branch/); + const categories = parseChangelogCategories("### Fixed\n\n- Fixed a thing\n\n### Added\n- Added a thing\n"); + assert.equal( + renderChangelogReleaseBody(categories), + "### Added\n\n- Added a thing\n\n### Fixed\n\n- Fixed a thing", + ); +}); + +test("updates a release changelog and merges matching RC entries into a final release", () => { + const changelog = `# Changelog + +## [Unreleased] + +### Added + +- Final addition + +## [2.9.0rc1] - 2026-08-01 +### Fixed + +- RC fix + +## [2.8.1] - 2026-07-25 +### Changed + +- Previous change + +[unreleased]: https://github.com/Pycord-Development/pycord/compare/v2.9.0rc1...HEAD +[2.9.0rc1]: https://github.com/Pycord-Development/pycord/compare/v2.8.1...v2.9.0rc1 +`; + const updated = updateChangelogText({ + text: changelog, + version: "2.9.0", + previousTag: "v2.9.0rc1", + previousFinalTag: "v2.8.1", + branch: "master", + repository: "Pycord-Development/pycord", + date: "2026-08-23", + }); + assert.match(updated, /^## \[2\.9\.0] - 2026-08-23$/m); + assert.match(updated, /- Final addition/); + assert.match(updated, /- RC fix/); + assert.match(updated, /^\[2\.9\.0]: .*compare\/v2\.8\.1\.\.\.v2\.9\.0$/m); + assert.match(updated, /^\[unreleased]: .*compare\/v2\.9\.0\.\.\.HEAD$/m); + assert.deepEqual( + assertChangelogPreparedText(updated, "2.9.0", "Pycord-Development/pycord"), + { version: "2.9.0", prepared: true }, + ); + assert.throws( + () => + updateChangelogText({ + text: updated, + version: "2.9.0", + previousTag: "v2.9.0rc1", + previousFinalTag: "v2.8.1", + branch: "master", + repository: "Pycord-Development/pycord", + date: "2026-08-23", + }), + /already contains/, + ); +}); + +test("rejects changelogs without an Unreleased section", () => { + assert.throws( + () => + updateChangelogText({ + text: "# Changelog\n", + version: "2.9.0", + previousTag: "v2.8.1", + branch: "master", + repository: "Pycord-Development/pycord", + date: "2026-08-23", + }), + /missing.*Unreleased/i, + ); +}); + +test("derives stable and RC Read the Docs versions", () => { + assert.deepEqual(deriveReadTheDocsRelease("2.9.0"), { + version: "2.9.0", + docsVersion: "v2.9.0", + hidden: false, + }); + assert.deepEqual(deriveReadTheDocsRelease("2.9.0rc1"), { + version: "2.9.0rc1", + docsVersion: "v2.9.x", + hidden: true, + }); +}); + +test("Read the Docs dry run does not require a token", async () => { + const result = await manageReadTheDocsRelease({ version: "2.9.0", sync: true, dryRun: true }); + assert.equal(result.docsVersion, "v2.9.0"); + assert.equal(result.sync, true); +}); + +test("Read the Docs sync waits for the version before activation", async () => { + const calls = []; + const statuses = [202, 404, 404, 200, 200]; + const result = await manageReadTheDocsRelease( + { version: "2.9.0rc1", token: "token", sync: true, attempts: 3, retryDelayMs: 0 }, + { + fetchImplementation: async (url, options) => { + calls.push({ url, options }); + return response(statuses.shift()); + }, + delayImplementation: async () => {}, + }, + ); + assert.equal(result.docsVersion, "v2.9.x"); + assert.deepEqual(calls.map((call) => call.options.method), ["POST", "GET", "GET", "GET", "PATCH"]); + assert.equal(JSON.parse(calls.at(-1).options.body).hidden, true); +}); + +test("Read the Docs sync fails when the version never appears", async () => { + await assert.rejects( + manageReadTheDocsRelease( + { version: "2.9.0", token: "token", sync: true, attempts: 2, retryDelayMs: 0 }, + { + fetchImplementation: async (url, options) => response(options.method === "POST" ? 202 : 404), + delayImplementation: async () => {}, + }, + ), + /did not appear/, + ); +}); + +test("builds Discord payloads for final and RC releases", () => { + const finalMessage = buildDiscordReleaseMessage({ + version: "2.9.0", + previousTag: "v2.9.0rc1", + previousFinalTag: "v2.8.1", + repository: "Pycord-Development/pycord", + }); + assert.match(finalMessage, /@everyone/); + assert.match(finalMessage, /compare\/v2\.8\.1\.\.\.v2\.9\.0/); + const rcPayload = buildDiscordReleasePayload({ + version: "2.9.0rc1", + previousTag: "v2.8.1", + repository: "Pycord-Development/pycord", + }); + assert.match(rcPayload.content, /@here/); + assert.deepEqual(rcPayload.allowed_mentions, { parse: ["everyone", "roles"] }); +}); + +test("Discord dry run does not require a webhook and live send validates the endpoint", async () => { + const options = { + version: "2.9.0", + previousTag: "v2.8.1", + repository: "Pycord-Development/pycord", + }; + assert.equal((await sendDiscordReleaseNotification({ ...options, dryRun: true })).sent, false); + await assert.rejects( + sendDiscordReleaseNotification({ ...options, webhookUrl: "https://example.com/hook" }), + /Discord webhook URL/, + ); + let request; + const sent = await sendDiscordReleaseNotification( + { ...options, webhookUrl: "https://discord.com/api/webhooks/1/secret" }, + async (url, init) => { + request = { url: String(url), init }; + return response(204); + }, + ); + assert.equal(sent.sent, true); + assert.equal(request.init.method, "POST"); + assert.equal(JSON.parse(request.init.body).allowed_mentions.parse[0], "everyone"); +}); + +test("artifact digest helper agrees with Node crypto", () => { + const artifacts = createArtifacts(); + const expected = `sha256:${createHash("sha256").update("wheel-content").digest("hex")}`; + assert.equal(artifacts.wheelDigest, expected); +}); diff --git a/.github/workflows/lib-checks.yml b/.github/workflows/lib-checks.yml index 4612442a92..f6d41cd1fb 100644 --- a/.github/workflows/lib-checks.yml +++ b/.github/workflows/lib-checks.yml @@ -8,7 +8,10 @@ on: - "uv.lock" - "tests/**" - ".github/actions/**" + - ".github/scripts/**" - ".github/workflows/lib-checks.yml" + - ".github/workflows/release_dev.yml" + - ".github/workflows/release_prod.yml.template" - "LICENSE" - "README.rst" - "*.toml" @@ -33,6 +36,7 @@ jobs: runs-on: ubuntu-latest outputs: lib: ${{ steps.filter.outputs.lib }} + release_tools: ${{ steps.filter.outputs.release_tools }} steps: - name: "Check changed paths" id: filter @@ -41,6 +45,7 @@ jobs: script: | if (context.eventName !== 'pull_request') { core.setOutput('lib', 'true'); + core.setOutput('release_tools', 'true'); return; } @@ -50,7 +55,7 @@ jobs: pull_number: context.payload.pull_request.number, }); - const matches = (file) => ( + const matchesLibrary = (file) => ( file.startsWith('discord/') || file.startsWith('examples/') || file === 'uv.lock' || @@ -64,10 +69,19 @@ jobs: /^\.[^/]+$/.test(file) ); - const changed = files.some((file) => matches(file.filename)); - core.setOutput('lib', changed ? 'true' : 'false'); + const matchesReleaseTools = (file) => ( + file.startsWith('.github/scripts/') || + file === '.github/workflows/lib-checks.yml' || + file === '.github/workflows/release_dev.yml' || + file === '.github/workflows/release_prod.yml.template' + ); + + const libraryChanged = files.some((file) => matchesLibrary(file.filename)); + const releaseToolsChanged = files.some((file) => matchesReleaseTools(file.filename)); + core.setOutput('lib', libraryChanged ? 'true' : 'false'); + core.setOutput('release_tools', releaseToolsChanged ? 'true' : 'false'); - if (!changed) { + if (!libraryChanged) { core.notice('Library checks skipped because no changed files matched the library check paths.'); await core.summary .addHeading('Library checks skipped', 2) @@ -75,6 +89,24 @@ jobs: .write(); } + if (!releaseToolsChanged) { + core.notice('Release tooling checks skipped because no changed files matched the release tooling paths.'); + } + + release-tooling: + needs: [ changes ] + if: ${{ needs.changes.outputs.release_tools == 'true' }} + runs-on: ubuntu-latest + steps: + - name: "Checkout Repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: "Setup Node.js" + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + - name: "Test release tooling" + run: node --test .github/scripts/release-tools.test.mjs + codespell: needs: [ changes ] if: ${{ needs.changes.outputs.lib == 'true' && github.event_name != 'schedule' }} @@ -222,7 +254,7 @@ jobs: run: tox tests-pass: # ref: https://github.com/orgs/community/discussions/4324#discussioncomment-3477871 runs-on: ubuntu-latest - needs: [ changes, tests, tests-full ] + needs: [ changes, release-tooling, tests, tests-full ] if: always() steps: - name: Tests succeeded diff --git a/.github/workflows/release_dev.yml b/.github/workflows/release_dev.yml new file mode 100644 index 0000000000..6520a33323 --- /dev/null +++ b/.github/workflows/release_dev.yml @@ -0,0 +1,271 @@ +name: "Development Release" + +on: + workflow_dispatch: + inputs: + version: + type: string + description: "PEP 440 development version to release (for example, 2.8.2.dev1)" + required: true + +permissions: {} + +concurrency: + group: pycord-development-release + cancel-in-progress: false + +jobs: + release: + name: "Build, attest, and publish py-cord-dev" + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/py-cord-dev + permissions: + contents: write + id-token: write + attestations: write + env: + VERSION: ${{ inputs.version }} + steps: + - name: "Security Check" + uses: Pycord-Development/execute-whitelist-action@107fcb23ce15f46d7fa11ffceb0d803140d7f220 # v2.2.0 + with: + whitelisted-github-ids: ${{ vars.ALLOWED_USER_IDS }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Checkout Repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + + - name: "Setup Node.js" + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + + # Production reuse: this derives the production tag, title, branch, and artifact names when channel=production. + - name: "Validate and derive release values" + id: release + run: >- + node .github/scripts/release-tools.mjs derive + --channel dev + --version "$VERSION" + + - name: "Verify the triggering commit" + env: + EXPECTED_COMMIT: ${{ github.sha }} + run: test "$(git rev-parse HEAD)" = "$EXPECTED_COMMIT" + + - name: "Derive reproducible build timestamp" + id: source_date + run: >- + node .github/scripts/release-tools.mjs source-date-epoch + --repository "$GITHUB_WORKSPACE" + --commit "$GITHUB_SHA" + + - name: "Require immutable GitHub releases" + env: + GH_TOKEN: ${{ github.token }} + IMMUTABLE_STATE: ${{ runner.temp }}/immutable-releases.json + run: | + gh api "repos/$GITHUB_REPOSITORY/immutable-releases" > "$IMMUTABLE_STATE" + node .github/scripts/release-tools.mjs check-immutable --state-file "$IMMUTABLE_STATE" + + - name: "Require an unused PyPI development version" + run: >- + node .github/scripts/release-tools.mjs check-pypi-unused + --project py-cord-dev + --version "$VERSION" + + - name: "Setup uv" + uses: ./.github/actions/setup-uv + with: + python-version: "3.14" + groups: "release" + frozen: "true" + + - name: "Prepare isolated py-cord-dev source" + id: source + env: + SOURCE_DIR: ${{ runner.temp }}/pycord-dev-source + run: >- + node .github/scripts/release-tools.mjs prepare-dev-source + --source "$GITHUB_WORKSPACE" + --destination "$SOURCE_DIR" + + # Production reuse: production builds directly from the checkout with the same UV and Hatch VCS path. + - name: "Build wheel and source distribution" + working-directory: ${{ steps.source.outputs.source_dir }} + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.release.outputs.version }} + SOURCE_DATE_EPOCH: ${{ steps.source_date.outputs.source_date_epoch }} + run: uv build --no-sources --clear --no-create-gitignore + + # Production reuse: channel=production validates py-cord artifact names with the same strict policy. + - name: "Validate and stage distributions" + id: artifacts + run: >- + node .github/scripts/release-tools.mjs stage-artifacts + --channel dev + --version "$VERSION" + --source-dir "${{ steps.source.outputs.source_dir }}/dist" + --destination "$GITHUB_WORKSPACE/dist-dev" + + - name: "Validate distribution metadata" + run: twine check --strict "${{ steps.artifacts.outputs.wheel_path }}" "${{ steps.artifacts.outputs.sdist_path }}" + + - name: "Install and import the built wheel" + env: + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + WHEEL_PATH: ${{ steps.artifacts.outputs.wheel_path }} + VERIFY_ENV: ${{ runner.temp }}/pycord-dev-verify + run: | + uv venv --clear --python 3.14 "$VERIFY_ENV" + uv pip install --python "$VERIFY_ENV/bin/python" "$WHEEL_PATH" + cd "$VERIFY_ENV" + "$VERIFY_ENV/bin/python" -c 'import importlib.metadata as metadata, os; import discord; expected = os.environ["EXPECTED_VERSION"]; assert metadata.version("py-cord-dev") == expected; assert discord.__version__ == expected' + + - name: "Generate build provenance attestations" + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + with: + subject-path: | + ${{ steps.artifacts.outputs.wheel_path }} + ${{ steps.artifacts.outputs.sdist_path }} + + - name: "Verify wheel provenance" + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh attestation verify "${{ steps.artifacts.outputs.wheel_path }}" + --bundle "${{ steps.attest.outputs.bundle-path }}" + --repo "$GITHUB_REPOSITORY" + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release_dev.yml" + --source-digest "$GITHUB_SHA" + --deny-self-hosted-runners + + - name: "Verify source distribution provenance" + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh attestation verify "${{ steps.artifacts.outputs.sdist_path }}" + --bundle "${{ steps.attest.outputs.bundle-path }}" + --repo "$GITHUB_REPOSITORY" + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release_dev.yml" + --source-digest "$GITHUB_SHA" + --deny-self-hosted-runners + + # Production reuse: the production migration will use the same state check before its explicit Git writes. + - name: "Check development tag state" + id: tag + run: >- + node .github/scripts/release-tools.mjs check-tag + --repository "$GITHUB_WORKSPACE" + --remote origin + --tag "${{ steps.release.outputs.tag }}" + --expected-commit "$GITHUB_SHA" + + # Production reuse: release state and asset digests are channel-aware and shared with the future production workflow. + - name: "Check development release state" + id: release_state + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node .github/scripts/release-tools.mjs check-github-release + --channel dev + --version "$VERSION" + --expected-commit "$GITHUB_SHA" + --dist-dir "${{ steps.artifacts.outputs.dist_dir }}" + --repository "$GITHUB_REPOSITORY" + --tag-state "${{ steps.tag.outputs.tag_state }}" + + - name: "Create and push annotated development tag" + if: ${{ steps.tag.outputs.create_tag == 'true' }} + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag --annotate "$TAG" "$GITHUB_SHA" --message "Development release $VERSION" + git push origin "refs/tags/$TAG" + + - name: "Verify final development tag state" + id: final_tag + run: >- + node .github/scripts/release-tools.mjs check-tag + --repository "$GITHUB_WORKSPACE" + --remote origin + --tag "${{ steps.release.outputs.tag }}" + --expected-commit "$GITHUB_SHA" + + # Production reuse: GitHub release mutations stay explicit while the helper validates all policy and state. + - name: "Create immutable GitHub prerelease" + if: ${{ steps.release_state.outputs.create_release == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + TITLE: ${{ steps.release.outputs.title }} + run: >- + gh release create "$TAG" + "${{ steps.artifacts.outputs.wheel_path }}" + "${{ steps.artifacts.outputs.sdist_path }}" + --repo "$GITHUB_REPOSITORY" + --verify-tag + --target "$GITHUB_SHA" + --title "$TITLE" + --generate-notes + --prerelease + --latest=false + + - name: "Upload missing wheel to interrupted draft" + if: ${{ steps.release_state.outputs.resume_draft == 'true' && steps.release_state.outputs.wheel_missing == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: gh release upload "$TAG" "${{ steps.artifacts.outputs.wheel_path }}" --repo "$GITHUB_REPOSITORY" + + - name: "Upload missing source distribution to interrupted draft" + if: ${{ steps.release_state.outputs.resume_draft == 'true' && steps.release_state.outputs.sdist_missing == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: gh release upload "$TAG" "${{ steps.artifacts.outputs.sdist_path }}" --repo "$GITHUB_REPOSITORY" + + - name: "Publish interrupted draft as a prerelease" + if: ${{ steps.release_state.outputs.resume_draft == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + TITLE: ${{ steps.release.outputs.title }} + run: >- + gh release edit "$TAG" + --repo "$GITHUB_REPOSITORY" + --verify-tag + --target "$GITHUB_SHA" + --title "$TITLE" + --draft=false + --prerelease + --latest=false + + - name: "Verify immutable release and assets" + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node .github/scripts/release-tools.mjs check-github-release + --channel dev + --version "$VERSION" + --expected-commit "$GITHUB_SHA" + --dist-dir "${{ steps.artifacts.outputs.dist_dir }}" + --repository "$GITHUB_REPOSITORY" + --tag-state "${{ steps.final_tag.outputs.tag_state }}" + + # Production reuse: trusted publishing uses the same action without static PyPI credentials. + - name: "Publish distributions to PyPI" + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist-dev/ + + # Production-only follow-up commands are update-changelog, rtd-release, close-milestone, and notify-discord. + # Development releases intentionally stop after trusted publishing. diff --git a/.github/workflows/release_prod.yml.template b/.github/workflows/release_prod.yml.template new file mode 100644 index 0000000000..726f1c8031 --- /dev/null +++ b/.github/workflows/release_prod.yml.template @@ -0,0 +1,538 @@ +# DISABLED PRODUCTION TEMPLATE +# +# GitHub ignores this file because it does not end in .yml or .yaml. +# To activate it, review it after the development rehearsal and replace +# .github/workflows/release.yml with this file's contents. +# +# Do not deploy this as release_prod.yml. The py-cord trusted publisher on PyPI +# is bound to repository Pycord-Development/pycord, environment release, and +# the exact workflow filename release.yml. + +name: "Release" + +on: + workflow_dispatch: + inputs: + operation: + type: choice + description: "Release phase to run" + required: true + options: + - prepare + - publish + - finalize + version: + type: string + description: "Canonical production version (for example, 2.9.0 or 2.9.0rc1)" + required: true + sync_readthedocs: + type: boolean + description: "Finalize: sync and activate the matching Read the Docs version" + required: true + default: true + close_milestone: + type: boolean + description: "Finalize: idempotently close the exact release milestone" + required: true + default: true + notify_discord: + type: boolean + description: "Finalize: send Discord notification (not idempotent; default off)" + required: true + default: false + +permissions: {} + +concurrency: + group: pycord-production-release + cancel-in-progress: false + +jobs: + prepare: + name: "Prepare committed release state" + if: ${{ inputs.operation == 'prepare' }} + runs-on: ubuntu-latest + environment: + name: release + permissions: + contents: read + env: + VERSION: ${{ inputs.version }} + steps: + - name: "Security Check" + uses: Pycord-Development/execute-whitelist-action@107fcb23ce15f46d7fa11ffceb0d803140d7f220 # v2.2.0 + with: + whitelisted-github-ids: ${{ vars.ALLOWED_USER_IDS }} + token: ${{ secrets.GITHUB_TOKEN }} + + # ADMIN_GITHUB_TOKEN authenticates as NyuwBot. The NyuwBot ruleset team is + # the explicit bypass actor for protected master and version-branch writes. + - name: "Checkout Repository as NyuwBot" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + token: ${{ secrets.ADMIN_GITHUB_TOKEN }} + persist-credentials: true + fetch-depth: 0 + fetch-tags: true + + - name: "Setup Node.js" + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + + - name: "Validate and derive production release values" + id: release + run: >- + node .github/scripts/release-tools.mjs derive + --channel production + --version "$VERSION" + + - name: "Require a branch dispatch and verify checkout" + env: + EXPECTED_COMMIT: ${{ github.sha }} + REF_TYPE: ${{ github.ref_type }} + BRANCH: ${{ github.ref_name }} + run: | + set -euo pipefail + test "$REF_TYPE" = "branch" + git check-ref-format --branch "$BRANCH" + test "$(git rev-parse HEAD)" = "$EXPECTED_COMMIT" + test -z "$(git status --porcelain)" + + - name: "Require the NyuwBot automation identity" + env: + ADMIN_GITHUB_TOKEN: ${{ secrets.ADMIN_GITHUB_TOKEN }} + run: >- + node .github/scripts/release-tools.mjs check-github-identity + --expected-login NyuwBot + --token-env ADMIN_GITHUB_TOKEN + + - name: "Derive previous production tags" + id: history + run: >- + node .github/scripts/release-tools.mjs release-history + --repository "$GITHUB_WORKSPACE" + --version "$VERSION" + + - name: "Prepare production changelog" + env: + PREVIOUS_TAG: ${{ steps.history.outputs.previous_tag }} + PREVIOUS_FINAL_TAG: ${{ steps.history.outputs.previous_final_tag }} + BRANCH: ${{ github.ref_name }} + run: >- + node .github/scripts/release-tools.mjs update-changelog + --path CHANGELOG.md + --version "$VERSION" + --previous-tag "$PREVIOUS_TAG" + --previous-final-tag "$PREVIOUS_FINAL_TAG" + --branch "$BRANCH" + --repository "$GITHUB_REPOSITORY" + + - name: "Commit prepared changelog as NyuwBot" + run: | + set -euo pipefail + git config user.name "NyuwBot" + git config user.email "nyuw@aitsys.dev" + git add -- CHANGELOG.md + git diff --cached --exit-code && { + echo "::error::The changelog helper produced no committed change." + exit 1 + } + git commit -m "chore(release): update CHANGELOG.md for version $VERSION" + + - name: "Update version branch with an explicit lease" + env: + VERSION_BRANCH: ${{ steps.release.outputs.version_branch }} + run: | + set -euo pipefail + git check-ref-format --branch "$VERSION_BRANCH" + remote_line="$(git ls-remote --heads origin "refs/heads/$VERSION_BRANCH")" + if [[ "$(printf '%s\n' "$remote_line" | sed '/^$/d' | wc -l)" -gt 1 ]]; then + echo "::error::Remote returned ambiguous state for refs/heads/$VERSION_BRANCH." + exit 1 + fi + remote_sha="${remote_line%%[[:space:]]*}" + git push \ + --force-with-lease="refs/heads/$VERSION_BRANCH:$remote_sha" \ + origin "HEAD:refs/heads/$VERSION_BRANCH" + + - name: "Push prepared commit to the dispatch branch" + env: + BRANCH: ${{ github.ref_name }} + run: git push origin "HEAD:refs/heads/$BRANCH" + + publish: + name: "Build, attest, and publish py-cord" + if: ${{ inputs.operation == 'publish' }} + runs-on: ubuntu-latest + environment: + name: release + url: https://pypi.org/p/py-cord + permissions: + contents: write + id-token: write + attestations: write + env: + VERSION: ${{ inputs.version }} + steps: + - name: "Security Check" + uses: Pycord-Development/execute-whitelist-action@107fcb23ce15f46d7fa11ffceb0d803140d7f220 # v2.2.0 + with: + whitelisted-github-ids: ${{ vars.ALLOWED_USER_IDS }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Checkout Repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + + - name: "Setup Node.js" + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + + - name: "Validate and derive production release values" + id: release + run: >- + node .github/scripts/release-tools.mjs derive + --channel production + --version "$VERSION" + + - name: "Verify prepared source commit" + env: + EXPECTED_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_COMMIT" + test -z "$(git status --porcelain)" + node .github/scripts/release-tools.mjs check-changelog \ + --path CHANGELOG.md \ + --version "$VERSION" \ + --repository "$GITHUB_REPOSITORY" + + - name: "Derive reproducible build timestamp" + id: source_date + run: >- + node .github/scripts/release-tools.mjs source-date-epoch + --repository "$GITHUB_WORKSPACE" + --commit "$GITHUB_SHA" + + - name: "Require immutable GitHub releases" + env: + GH_TOKEN: ${{ github.token }} + IMMUTABLE_STATE: ${{ runner.temp }}/immutable-releases.json + run: | + gh api "repos/$GITHUB_REPOSITORY/immutable-releases" > "$IMMUTABLE_STATE" + node .github/scripts/release-tools.mjs check-immutable --state-file "$IMMUTABLE_STATE" + + - name: "Require an unused PyPI production version" + run: >- + node .github/scripts/release-tools.mjs check-pypi-unused + --project py-cord + --version "$VERSION" + + - name: "Setup uv" + uses: ./.github/actions/setup-uv + with: + python-version: "3.14" + groups: "release" + frozen: "true" + + - name: "Build wheel and source distribution" + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.release.outputs.version }} + SOURCE_DATE_EPOCH: ${{ steps.source_date.outputs.source_date_epoch }} + run: uv build --no-sources --clear --no-create-gitignore + + - name: "Validate production distributions" + id: artifacts + run: >- + node .github/scripts/release-tools.mjs validate-artifacts + --channel production + --version "$VERSION" + --dist-dir "$GITHUB_WORKSPACE/dist" + + - name: "Validate distribution metadata" + run: twine check --strict "${{ steps.artifacts.outputs.wheel_path }}" "${{ steps.artifacts.outputs.sdist_path }}" + + - name: "Install and import the built wheel" + env: + EXPECTED_VERSION: ${{ steps.release.outputs.version }} + WHEEL_PATH: ${{ steps.artifacts.outputs.wheel_path }} + VERIFY_ENV: ${{ runner.temp }}/pycord-prod-verify + run: | + uv venv --clear --python 3.14 "$VERIFY_ENV" + uv pip install --python "$VERIFY_ENV/bin/python" "$WHEEL_PATH" + cd "$VERIFY_ENV" + "$VERIFY_ENV/bin/python" -c 'import importlib.metadata as metadata, os; import discord; expected = os.environ["EXPECTED_VERSION"]; assert metadata.version("py-cord") == expected; assert discord.__version__ == expected' + + - name: "Generate build provenance attestations" + id: attest + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.0.0 + with: + subject-path: | + ${{ steps.artifacts.outputs.wheel_path }} + ${{ steps.artifacts.outputs.sdist_path }} + + - name: "Verify wheel provenance" + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh attestation verify "${{ steps.artifacts.outputs.wheel_path }}" + --bundle "${{ steps.attest.outputs.bundle-path }}" + --repo "$GITHUB_REPOSITORY" + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" + --source-digest "$GITHUB_SHA" + --deny-self-hosted-runners + + - name: "Verify source distribution provenance" + env: + GH_TOKEN: ${{ github.token }} + run: >- + gh attestation verify "${{ steps.artifacts.outputs.sdist_path }}" + --bundle "${{ steps.attest.outputs.bundle-path }}" + --repo "$GITHUB_REPOSITORY" + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/release.yml" + --source-digest "$GITHUB_SHA" + --deny-self-hosted-runners + + - name: "Check production tag state" + id: tag + run: >- + node .github/scripts/release-tools.mjs check-tag + --repository "$GITHUB_WORKSPACE" + --remote origin + --tag "${{ steps.release.outputs.tag }}" + --expected-commit "$GITHUB_SHA" + + - name: "Check production release state" + id: release_state + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node .github/scripts/release-tools.mjs check-github-release + --channel production + --version "$VERSION" + --expected-commit "$GITHUB_SHA" + --dist-dir "${{ steps.artifacts.outputs.dist_dir }}" + --repository "$GITHUB_REPOSITORY" + --tag-state "${{ steps.tag.outputs.tag_state }}" + + - name: "Create and push annotated production tag" + if: ${{ steps.tag.outputs.create_tag == 'true' }} + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag --annotate "$TAG" "$GITHUB_SHA" --message "Release $VERSION" + git push origin "refs/tags/$TAG" + + - name: "Verify final production tag state" + id: final_tag + run: >- + node .github/scripts/release-tools.mjs check-tag + --repository "$GITHUB_WORKSPACE" + --remote origin + --tag "${{ steps.release.outputs.tag }}" + --expected-commit "$GITHUB_SHA" + + - name: "Create immutable GitHub release" + if: ${{ steps.release_state.outputs.create_release == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + TITLE: ${{ steps.release.outputs.title }} + PRERELEASE: ${{ steps.release.outputs.prerelease }} + LATEST: ${{ steps.release.outputs.latest }} + run: >- + gh release create "$TAG" + "${{ steps.artifacts.outputs.wheel_path }}" + "${{ steps.artifacts.outputs.sdist_path }}" + --repo "$GITHUB_REPOSITORY" + --verify-tag + --target "$GITHUB_SHA" + --title "$TITLE" + --generate-notes + --prerelease="$PRERELEASE" + --latest="$LATEST" + + - name: "Upload missing wheel to interrupted draft" + if: ${{ steps.release_state.outputs.resume_draft == 'true' && steps.release_state.outputs.wheel_missing == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: gh release upload "$TAG" "${{ steps.artifacts.outputs.wheel_path }}" --repo "$GITHUB_REPOSITORY" + + - name: "Upload missing source distribution to interrupted draft" + if: ${{ steps.release_state.outputs.resume_draft == 'true' && steps.release_state.outputs.sdist_missing == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + run: gh release upload "$TAG" "${{ steps.artifacts.outputs.sdist_path }}" --repo "$GITHUB_REPOSITORY" + + - name: "Publish interrupted draft" + if: ${{ steps.release_state.outputs.resume_draft == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + TITLE: ${{ steps.release.outputs.title }} + PRERELEASE: ${{ steps.release.outputs.prerelease }} + LATEST: ${{ steps.release.outputs.latest }} + run: >- + gh release edit "$TAG" + --repo "$GITHUB_REPOSITORY" + --verify-tag + --target "$GITHUB_SHA" + --title "$TITLE" + --draft=false + --prerelease="$PRERELEASE" + --latest="$LATEST" + + - name: "Verify immutable release and assets" + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node .github/scripts/release-tools.mjs check-github-release + --channel production + --version "$VERSION" + --expected-commit "$GITHUB_SHA" + --dist-dir "${{ steps.artifacts.outputs.dist_dir }}" + --repository "$GITHUB_REPOSITORY" + --tag-state "${{ steps.final_tag.outputs.tag_state }}" + + - name: "Publish distributions to PyPI" + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + packages-dir: dist/ + + # This phase intentionally ends after trusted publishing. + + finalize: + name: "Finalize published release" + if: ${{ inputs.operation == 'finalize' }} + runs-on: ubuntu-latest + environment: + name: release + permissions: + contents: read + issues: write + env: + VERSION: ${{ inputs.version }} + steps: + - name: "Security Check" + uses: Pycord-Development/execute-whitelist-action@107fcb23ce15f46d7fa11ffceb0d803140d7f220 # v2.2.0 + with: + whitelisted-github-ids: ${{ vars.ALLOWED_USER_IDS }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: "Checkout Repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + fetch-tags: true + + - name: "Setup Node.js" + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24" + + - name: "Validate and derive production release values" + id: release + run: >- + node .github/scripts/release-tools.mjs derive + --channel production + --version "$VERSION" + + - name: "Derive previous production tags" + id: history + run: >- + node .github/scripts/release-tools.mjs release-history + --repository "$GITHUB_WORKSPACE" + --version "$VERSION" + + - name: "Resolve published annotated tag" + id: tag + run: >- + node .github/scripts/release-tools.mjs resolve-tag + --repository "$GITHUB_WORKSPACE" + --remote origin + --tag "${{ steps.release.outputs.tag }}" + + - name: "Download immutable release assets" + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + WHEEL: ${{ steps.release.outputs.wheel_name }} + SDIST: ${{ steps.release.outputs.sdist_name }} + run: | + set -euo pipefail + test ! -e "$GITHUB_WORKSPACE/dist" + mkdir "$GITHUB_WORKSPACE/dist" + gh release download "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "$WHEEL" \ + --pattern "$SDIST" \ + --dir "$GITHUB_WORKSPACE/dist" + + - name: "Validate downloaded distributions" + id: artifacts + run: >- + node .github/scripts/release-tools.mjs validate-artifacts + --channel production + --version "$VERSION" + --dist-dir "$GITHUB_WORKSPACE/dist" + + - name: "Verify immutable GitHub release and assets" + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node .github/scripts/release-tools.mjs check-github-release + --channel production + --version "$VERSION" + --expected-commit "${{ steps.tag.outputs.tag_commit }}" + --dist-dir "${{ steps.artifacts.outputs.dist_dir }}" + --repository "$GITHUB_REPOSITORY" + --tag-state "${{ steps.tag.outputs.tag_state }}" + + - name: "Verify exact PyPI publication" + run: >- + node .github/scripts/release-tools.mjs check-pypi-published + --project py-cord + --channel production + --version "$VERSION" + --dist-dir "${{ steps.artifacts.outputs.dist_dir }}" + + - name: "Sync and activate Read the Docs version" + if: ${{ inputs.sync_readthedocs }} + env: + READTHEDOCS_TOKEN: ${{ secrets.READTHEDOCS_TOKEN }} + run: >- + node .github/scripts/release-tools.mjs rtd-release + --project pycord + --version "$VERSION" + --sync true + --attempts 24 + + - name: "Close exact release milestone" + if: ${{ inputs.close_milestone }} + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + node .github/scripts/release-tools.mjs close-milestone + --repository "$GITHUB_REPOSITORY" + --version "$VERSION" + + - name: "Notify Discord" + if: ${{ inputs.notify_discord }} + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + PREVIOUS_TAG: ${{ steps.history.outputs.previous_tag }} + PREVIOUS_FINAL_TAG: ${{ steps.history.outputs.previous_final_tag }} + run: >- + node .github/scripts/release-tools.mjs notify-discord + --version "$VERSION" + --previous-tag "$PREVIOUS_TAG" + --previous-final-tag "$PREVIOUS_FINAL_TAG" + --repository "$GITHUB_REPOSITORY"