versionary
Advanced tools
@@ -6,3 +6,3 @@ #!/usr/bin/env node | ||
| import { createReleasePlan } from "../release/plan.js"; | ||
| import { closeStaleReviewRequestIfExists, consumeNextReleaseFile, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, readNextReleaseHighlights, } from "../release/pr.js"; | ||
| import { closeStaleReviewRequestIfExists, consumeNextReleaseFile, isReleaseCommitMessage, openOrUpdateReviewRequest, prepareReleasePr, pushReleaseBranch, resolveReleaseHighlights, } from "../release/pr.js"; | ||
| import { runRelease, runReleaseDetailed } from "../release/release.js"; | ||
@@ -200,5 +200,6 @@ import { verifyProject } from "../release/verify-project.js"; | ||
| const loaded = loadConfig(); | ||
| const highlightsRead = readNextReleaseHighlights(process.cwd(), loaded.config); | ||
| const highlights = highlightsRead?.content ?? ""; | ||
| const section = renderReleasePlanChangelog(plan, { highlights }); | ||
| const highlightsResult = resolveReleaseHighlights(process.cwd(), loaded.config, plan.changelogFile, plan.changelogFormat, logger); | ||
| const section = renderReleasePlanChangelog(plan, { | ||
| highlights: highlightsResult.highlights, | ||
| }); | ||
| if (!write) { | ||
@@ -209,4 +210,4 @@ console.log(section); | ||
| prependChangelog(process.cwd(), plan.changelogFile, section, plan.changelogFormat); | ||
| if (highlightsRead) { | ||
| consumeNextReleaseFile(process.cwd(), highlightsRead.filePath); | ||
| if (highlightsResult.source === "file" && highlightsResult.filePath) { | ||
| consumeNextReleaseFile(process.cwd(), highlightsResult.filePath); | ||
| } | ||
@@ -213,0 +214,0 @@ console.log(`Updated ${plan.changelogFile}`); |
@@ -28,2 +28,22 @@ import { type ParsedCommit } from "../git/commits.js"; | ||
| export declare function renderSimpleChangelog(plan: SimplePlan): string; | ||
| /** | ||
| * Locate a manual-notes block at the top of an existing changelog and split it | ||
| * out. The notes block is the first release-level heading whose text is not a | ||
| * version (`##` for markdown, `#` for r-news), captured up to — but excluding — | ||
| * the first subsequent version heading at the same level. | ||
| * | ||
| * Returns the captured prose as `highlights` (the heading line itself dropped, | ||
| * trimmed) and the changelog with that block removed as `body`. When the first | ||
| * release-level heading is already a version, or there is none, nothing is | ||
| * stripped and `highlights` is empty. | ||
| * | ||
| * Only the topmost heading is ever eligible, and capture stops at the first | ||
| * version heading, so prior releases are never swallowed. For r-news this also | ||
| * subsumes the conventional `# pkg (development version)` header, whose body | ||
| * (if any) becomes the release notes. | ||
| */ | ||
| export declare function extractUnreleasedNotes(existing: string, format?: VersionaryChangelogFormat): { | ||
| highlights: string; | ||
| body: string; | ||
| }; | ||
| export declare function prependChangelog(cwd: string, changelogFile: string, section: string, format?: VersionaryChangelogFormat): void; | ||
@@ -30,0 +50,0 @@ export declare function renderRNewsReleaseNotes(input: { |
@@ -6,2 +6,3 @@ import fs from "node:fs"; | ||
| import { resolvePackageDependencies, } from "./plan.js"; | ||
| import { isVersionHeading } from "./semver.js"; | ||
| const REVIEW_REQUEST_FOOTER = "---\n\nThis PR was generated by [Versionary](https://github.com/jolars/versionary)."; | ||
@@ -205,11 +206,59 @@ function formatDate() { | ||
| } | ||
| /** | ||
| * Locate a manual-notes block at the top of an existing changelog and split it | ||
| * out. The notes block is the first release-level heading whose text is not a | ||
| * version (`##` for markdown, `#` for r-news), captured up to — but excluding — | ||
| * the first subsequent version heading at the same level. | ||
| * | ||
| * Returns the captured prose as `highlights` (the heading line itself dropped, | ||
| * trimmed) and the changelog with that block removed as `body`. When the first | ||
| * release-level heading is already a version, or there is none, nothing is | ||
| * stripped and `highlights` is empty. | ||
| * | ||
| * Only the topmost heading is ever eligible, and capture stops at the first | ||
| * version heading, so prior releases are never swallowed. For r-news this also | ||
| * subsumes the conventional `# pkg (development version)` header, whose body | ||
| * (if any) becomes the release notes. | ||
| */ | ||
| export function extractUnreleasedNotes(existing, format = "markdown-changelog") { | ||
| const headingPattern = format === "r-news" ? /^#(?!#)\s+/u : /^##(?!#)\s+/u; | ||
| const lines = existing.split("\n"); | ||
| let firstHeadingIdx = -1; | ||
| for (let index = 0; index < lines.length; index += 1) { | ||
| if (headingPattern.test(lines[index])) { | ||
| firstHeadingIdx = index; | ||
| break; | ||
| } | ||
| } | ||
| if (firstHeadingIdx === -1 || isVersionHeading(lines[firstHeadingIdx])) { | ||
| return { highlights: "", body: existing }; | ||
| } | ||
| let nextVersionIdx = lines.length; | ||
| for (let index = firstHeadingIdx + 1; index < lines.length; index += 1) { | ||
| if (headingPattern.test(lines[index]) && isVersionHeading(lines[index])) { | ||
| nextVersionIdx = index; | ||
| break; | ||
| } | ||
| } | ||
| const highlights = lines | ||
| .slice(firstHeadingIdx + 1, nextVersionIdx) | ||
| .join("\n") | ||
| .trim(); | ||
| const body = [ | ||
| ...lines.slice(0, firstHeadingIdx), | ||
| ...lines.slice(nextVersionIdx), | ||
| ].join("\n"); | ||
| return { highlights, body }; | ||
| } | ||
| export function prependChangelog(cwd, changelogFile, section, format = "markdown-changelog") { | ||
| const changelogPath = path.join(cwd, changelogFile); | ||
| const existing = fs.existsSync(changelogPath) | ||
| const existingRaw = fs.existsSync(changelogPath) | ||
| ? fs.readFileSync(changelogPath, "utf8") | ||
| : ""; | ||
| // Strip any manual-notes block; its prose is folded into `section` upstream, | ||
| // so leaving it here would duplicate it. | ||
| const { body: existing } = extractUnreleasedNotes(existingRaw, format); | ||
| if (format === "r-news") { | ||
| const bodyWithoutDevHeader = existing.replace(/^#\s+.+\s+\(development version\)\s*(?:\r?\n)*/u, ""); | ||
| const separator = bodyWithoutDevHeader.length > 0 ? "\n\n" : ""; | ||
| const next = `${`${section}${separator}${bodyWithoutDevHeader}`.trimEnd()}\n`; | ||
| const separator = existing.length > 0 ? "\n\n" : ""; | ||
| const next = `${`${section}${separator}${existing}`.trimEnd()}\n`; | ||
| fs.writeFileSync(changelogPath, next, "utf8"); | ||
@@ -216,0 +265,0 @@ return; |
+17
-1
| import type { ParsedCommit } from "../git/commits.js"; | ||
| import type { VersionaryConfig } from "../types/config.js"; | ||
| import type { VersionaryChangelogFormat, VersionaryConfig } from "../types/config.js"; | ||
| import type { VersionaryPluginContext } from "../types/plugins.js"; | ||
@@ -13,2 +13,18 @@ import { type ReleasePlan, type SimplePlan } from "./plan.js"; | ||
| }; | ||
| /** | ||
| * Read the manual-notes ("Unreleased") prose from the top of a changelog file. | ||
| * Returns an empty string when the file is absent or has no notes block. | ||
| */ | ||
| export declare function readChangelogHighlights(cwd: string, changelogFile: string, format: VersionaryChangelogFormat): string; | ||
| export interface ResolvedReleaseHighlights { | ||
| highlights: string; | ||
| source: "changelog" | "file" | "none"; | ||
| filePath?: string; | ||
| } | ||
| /** | ||
| * Resolve release highlights, preferring an editable "Unreleased" section at the | ||
| * top of the changelog. Falls back to the deprecated `NEXT_RELEASE.md` file | ||
| * (emitting a warning) so existing setups keep working. | ||
| */ | ||
| export declare function resolveReleaseHighlights(cwd: string, config: VersionaryConfig, changelogFile: string, format: VersionaryChangelogFormat, logger?: VersionaryPluginContext["logger"]): ResolvedReleaseHighlights; | ||
| export declare function splitSafeDirtyFiles(files: string[]): { | ||
@@ -15,0 +31,0 @@ ignored: string[]; |
+46
-8
@@ -9,3 +9,3 @@ import { execFileSync } from "node:child_process"; | ||
| import { applyConfiguredArtifactRules } from "./artifact-rules.js"; | ||
| import { prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, } from "./changelog.js"; | ||
| import { extractUnreleasedNotes, prependChangelog, renderReleaseNotesSection, renderReleasePlanChangelog, renderReviewRequestFooter, } from "./changelog.js"; | ||
| import { createReleasePlan, getChangelogDefaults, resolvePackageDependencies, } from "./plan.js"; | ||
@@ -53,2 +53,35 @@ import { getBaselineStatePath, writeBaselineSha, } from "./state.js"; | ||
| } | ||
| /** | ||
| * Read the manual-notes ("Unreleased") prose from the top of a changelog file. | ||
| * Returns an empty string when the file is absent or has no notes block. | ||
| */ | ||
| export function readChangelogHighlights(cwd, changelogFile, format) { | ||
| const changelogPath = path.join(cwd, changelogFile); | ||
| if (!fs.existsSync(changelogPath)) { | ||
| return ""; | ||
| } | ||
| const existing = fs.readFileSync(changelogPath, "utf8"); | ||
| return extractUnreleasedNotes(existing, format).highlights; | ||
| } | ||
| /** | ||
| * Resolve release highlights, preferring an editable "Unreleased" section at the | ||
| * top of the changelog. Falls back to the deprecated `NEXT_RELEASE.md` file | ||
| * (emitting a warning) so existing setups keep working. | ||
| */ | ||
| export function resolveReleaseHighlights(cwd, config, changelogFile, format, logger) { | ||
| const fromChangelog = readChangelogHighlights(cwd, changelogFile, format); | ||
| if (fromChangelog.length > 0) { | ||
| return { highlights: fromChangelog, source: "changelog" }; | ||
| } | ||
| const fromFile = readNextReleaseHighlights(cwd, config); | ||
| if (fromFile) { | ||
| logger?.warn(`${fromFile.filePath} is deprecated; add release notes under an "Unreleased" heading at the top of ${changelogFile} instead.`); | ||
| return { | ||
| highlights: fromFile.content, | ||
| source: "file", | ||
| filePath: fromFile.filePath, | ||
| }; | ||
| } | ||
| return { highlights: "", source: "none" }; | ||
| } | ||
| function listTrackedDirtyFiles(cwd) { | ||
@@ -269,4 +302,4 @@ const status = execFileSync("git", ["status", "--porcelain", "--untracked-files=no"], { | ||
| const packageReleaseMetadata = buildPackageReleaseMetadata(cwd, plan, loaded.config); | ||
| const nextReleaseRead = readNextReleaseHighlights(cwd, loaded.config); | ||
| const highlights = nextReleaseRead?.content ?? ""; | ||
| const highlightsResult = resolveReleaseHighlights(cwd, loaded.config, plan.changelogFile, plan.changelogFormat, options.logger); | ||
| const highlights = highlightsResult.highlights; | ||
| const section = renderReleasePlanChangelog(plan, { highlights, cwd }); | ||
@@ -276,6 +309,8 @@ prependChangelog(cwd, plan.changelogFile, section, plan.changelogFormat); | ||
| let consumedHighlightsPath = null; | ||
| if (nextReleaseRead) { | ||
| const { tracked } = consumeNextReleaseFile(cwd, nextReleaseRead.filePath); | ||
| // The changelog "Unreleased" block is stripped in-place by prependChangelog; | ||
| // only the legacy side file needs explicit consumption and staging. | ||
| if (highlightsResult.source === "file" && highlightsResult.filePath) { | ||
| const { tracked } = consumeNextReleaseFile(cwd, highlightsResult.filePath); | ||
| if (tracked) { | ||
| consumedHighlightsPath = nextReleaseRead.filePath; | ||
| consumedHighlightsPath = highlightsResult.filePath; | ||
| } | ||
@@ -299,2 +334,4 @@ } | ||
| } | ||
| const packageChangelogPath = path.posix.join(packagePlan.path, packageChangelogFile); | ||
| const packageHighlights = readChangelogHighlights(cwd, packageChangelogPath, "markdown-changelog"); | ||
| const packageSection = renderReleaseNotesSection({ | ||
@@ -307,5 +344,6 @@ currentVersion: packagePlan.currentVersion, | ||
| dependencies: resolvePackageDependencies(plan, packagePlan.path), | ||
| highlights: packageHighlights, | ||
| }); | ||
| prependChangelog(cwd, path.posix.join(packagePlan.path, packageChangelogFile), packageSection, "markdown-changelog"); | ||
| updatedChangelogFiles.push(path.posix.join(packagePlan.path, packageChangelogFile)); | ||
| prependChangelog(cwd, packageChangelogPath, packageSection, "markdown-changelog"); | ||
| updatedChangelogFiles.push(packageChangelogPath); | ||
| } | ||
@@ -312,0 +350,0 @@ const releaseTargets = buildReleaseTargets(cwd, plan, loaded.config); |
@@ -15,3 +15,15 @@ export type ReleaseType = "major" | "minor" | "patch" | null; | ||
| export declare function isValidVersion(version: string): boolean; | ||
| /** | ||
| * Decide whether a changelog heading denotes a released version (e.g. | ||
| * `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`) | ||
| * rather than a manual-notes heading (e.g. `## Unreleased`, `## Upcoming`). | ||
| * | ||
| * Intentionally liberal: a heading counts as a version if it contains any | ||
| * version-like token that {@link isValidVersion} accepts. This errs toward | ||
| * "it's a version", so a real release heading is never mistaken for a notes | ||
| * block (and therefore never stripped). Dates such as `2026-06-02` use dashes, | ||
| * not dots, so they never match the three-component token. | ||
| */ | ||
| export declare function isVersionHeading(heading: string): boolean; | ||
| export declare function compareVersions(leftRaw: string, rightRaw: string): number; | ||
| export declare function bumpVersion(current: string, releaseType: Exclude<ReleaseType, null>, options?: BumpVersionOptions): string; |
@@ -41,2 +41,21 @@ export function maxReleaseType(types) { | ||
| } | ||
| const VERSION_TOKEN_PATTERN = /v?(\d+\.\d+\.\d+(?:\.\d+)?)/u; | ||
| /** | ||
| * Decide whether a changelog heading denotes a released version (e.g. | ||
| * `## [0.28.2](url) (2026-06-02)`, `## 1.2.3`, `## v1.2.3`, `## 0.28.2.9000`) | ||
| * rather than a manual-notes heading (e.g. `## Unreleased`, `## Upcoming`). | ||
| * | ||
| * Intentionally liberal: a heading counts as a version if it contains any | ||
| * version-like token that {@link isValidVersion} accepts. This errs toward | ||
| * "it's a version", so a real release heading is never mistaken for a notes | ||
| * block (and therefore never stripped). Dates such as `2026-06-02` use dashes, | ||
| * not dots, so they never match the three-component token. | ||
| */ | ||
| export function isVersionHeading(heading) { | ||
| const match = heading.match(VERSION_TOKEN_PATTERN); | ||
| if (!match?.[1]) { | ||
| return false; | ||
| } | ||
| return isValidVersion(match[1]); | ||
| } | ||
| function isNumericIdentifier(identifier) { | ||
@@ -43,0 +62,0 @@ return /^(0|[1-9]\d*)$/u.test(identifier); |
+1
-1
| { | ||
| "name": "versionary", | ||
| "version": "0.28.2", | ||
| "version": "0.29.0", | ||
| "description": "Automatic release framework based on conventional commits and semantic versioning", | ||
@@ -5,0 +5,0 @@ "keywords": [ |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
263169
2.93%5994
2.65%