Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
883 changes: 798 additions & 85 deletions CHANGELOG.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1825,7 +1825,7 @@ pnpm build

- [`eslint.config.ts`](eslint.config.ts) dogfoods this package's own factory export on itself (`import { exadevConfig } from './src/index'`), spreading `exadevConfig({ react: false, nextjs: false, ... })`, with the [tooling wiring](#tooling-wiring) preset on for the sections it uses. React and Next.js are forced off explicitly, not left to the plain auto-detecting default, since `eslint-plugin-react`/`@next/eslint-plugin-next` are real devDependencies of *this* repo (needed to test [`src/react.ts`](src/react.ts)/[`src/nextjs.ts`](src/nextjs.ts)'s own "package is resolvable" branch) even though this repo is neither a React nor a Next.js project. `no-side-effects-in-index` and `no-non-barrel-reexport` self-scope to [`src/index.ts`](src/index.ts) internally, so no `files`/`ignores` wiring is needed here. Plugin construction lives in [`src/plugin.ts`](src/plugin.ts) specifically so `src/index.ts` stays a pure re-export point.
- [`tsconfig.json`](tsconfig.json) enables [`verbatimModuleSyntax`](https://www.typescriptlang.org/tsconfig/#verbatimModuleSyntax) (`import type`/`export type` required for type-only imports — also enforced by `consistent-type-imports`) and [`noUncheckedIndexedAccess`](https://www.typescriptlang.org/tsconfig/#noUncheckedIndexedAccess) (narrow indexed access before use rather than asserting).
- Conventional commits are enforced by [commitlint](https://commitlint.js.org), restricted to the type-enum defined once in [`release.config.ts`](release.config.ts)'s `commitTypes` — both commitlint and semantic-release derive from that single list.
- Conventional commits are enforced by [commitlint](https://commitlint.js.org), restricted to the type-enum defined once in [`release.config.ts`](release.config.ts)'s `commitTypes` — commitlint, the release rules and the release notes' section titles all derive from that single list.

### Gotchas and quirks

Expand All @@ -1842,7 +1842,7 @@ Conventional commits are enforced by a husky `commit-msg` hook and re-checked in

### Release

Conventional commits drive [semantic-release](https://semantic-release.gitbook.io/semantic-release) on every push to `main`: version bump, `CHANGELOG.md`, GitHub Release, and npm publish via [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers) (no stored token). The release commit and tag are pushed over SSH with a repository deploy key that bypasses the `main` ruleset; the key is written to the runner's temporary directory only by the step directly before semantic-release (checkout does not receive it and persists no credentials) and removed afterwards, and the job's actions are pinned to commit SHAs. This narrows the key's exposure without isolating it: code run by an earlier step, or by semantic-release and its plugins from `node_modules`, can still reach it, so a credential that does not outlive the job (a short-lived GitHub App installation token) remains the complete fix. A second CI job republishes the identical build under the unscoped alias `exadev-eslint-config`.
Conventional commits drive [semantic-release](https://semantic-release.gitbook.io/semantic-release) on every push to `main`: version bump, `CHANGELOG.md`, GitHub Release, and npm publish via [OIDC trusted publishing](https://docs.npmjs.com/trusted-publishers) (no stored token). The release commit and tag are pushed over SSH with a repository deploy key that bypasses the `main` ruleset; the key is written to the runner's temporary directory only by the step directly before semantic-release (checkout does not receive it and persists no credentials) and removed afterwards, and the job's actions are pinned to commit SHAs. This narrows the key's exposure without isolating it: code run by an earlier step, or by semantic-release and its plugins from `node_modules`, can still reach it, so a credential that does not outlive the job (a short-lived GitHub App installation token) remains the complete fix. A second CI job republishes the identical build under the unscoped alias `exadev-eslint-config`. The release notes and `CHANGELOG.md` list every commit under the section titled for its type in `commitTypes` ([`release.config.ts`](release.config.ts)), in that list's order, so a release made only of `refactor`, `docs`, `test` or `chore` commits still has a body; the `chore(release)` commits are the one exclusion. The notes use the `angular` preset's layout with its commit transform replaced, because that preset hides every type but `feat`, `fix`, `perf` and `revert`; the `conventionalcommits` preset is not used, because the writer bundled with the release-notes generator does not render its templates.

## License

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@next/eslint-plugin-next": "^16.3.2",
"@semantic-release/changelog": "^7.0.0",
"@semantic-release/git": "^11.0.1",
"@semantic-release/release-notes-generator": "14.1.1",
"@stryker-mutator/api": "10.0.0",
"@stryker-mutator/core": "10.0.0",
"@stryker-mutator/typescript-checker": "10.0.0",
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions release-notes-generator.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* The release-notes generator plugin ships no type declarations. This declares the one function release.config.unit.test.ts calls, with the context fields that function reads.
*/
declare module '@semantic-release/release-notes-generator' {
export function generateNotes(
pluginConfig: Readonly<Record<string, unknown>>,
context: {
readonly cwd: string;
readonly commits: readonly { readonly hash: string; readonly message: string }[];
readonly options: { readonly repositoryUrl: string };
readonly lastRelease: { readonly gitTag: string };
readonly nextRelease: { readonly gitTag: string; readonly version: string };
},
): Promise<string>;
}
165 changes: 150 additions & 15 deletions release.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,155 @@ type ReleaseLevel = 'major' | 'minor' | 'patch' | false;
interface CommitType {
readonly type: string;
readonly release: ReleaseLevel;
/** Heading of the section that lists this type's commits in the release notes and CHANGELOG.md. */
readonly title: string;
}

/**
* Single source of truth for the conventional-commit types this project uses. commitlint's allowed type-enum (commitlint.config.ts imports this) and commit-analyzer's releaseRules below both derive from it, so a type can't trigger a release without also being accepted by commit-msg validation, or the reverse. Mirrors the identical pattern in documents.js/ooxml.js/etc.
* Single source of truth for the conventional-commit types this project uses. commitlint's allowed type-enum (commitlint.config.ts imports this), commit-analyzer's releaseRules and the release notes' sections below all derive from it, so a type can't trigger a release without also being accepted by commit-msg validation, or appear in a release without a section in its notes. The order is the order of the sections in the notes. Mirrors the identical pattern in documents.js/ooxml.js/etc.
*/
export const commitTypes: readonly CommitType[] = [
{ type: 'feat', release: 'minor' },
{ type: 'fix', release: 'patch' },
{ type: 'perf', release: 'patch' },
{ type: 'revert', release: 'patch' },
{ type: 'refactor', release: 'patch' },
{ type: 'docs', release: 'patch' },
{ type: 'style', release: 'patch' },
{ type: 'test', release: 'patch' },
{ type: 'build', release: 'patch' },
{ type: 'ci', release: 'patch' },
{ type: 'chore', release: 'patch' },
{ type: 'feat', release: 'minor', title: 'Features' },
{ type: 'fix', release: 'patch', title: 'Bug Fixes' },
{ type: 'perf', release: 'patch', title: 'Performance Improvements' },
{ type: 'revert', release: 'patch', title: 'Reverts' },
{ type: 'refactor', release: 'patch', title: 'Code Refactoring' },
{ type: 'docs', release: 'patch', title: 'Documentation' },
{ type: 'style', release: 'patch', title: 'Styles' },
{ type: 'test', release: 'patch', title: 'Tests' },
{ type: 'build', release: 'patch', title: 'Build System' },
{ type: 'ci', release: 'patch', title: 'Continuous Integration' },
{ type: 'chore', release: 'patch', title: 'Chores' },
];

/**
* Runs on `main`. Analyses commits since the last tag, bumps the version, publishes to npmjs.org (trusted OIDC publishing, no stored token -- see .github/workflows/ci.yml), creates a versioned tag and GitHub Release with generated notes, and commits CHANGELOG.md + package.json back to main.
* Type and scope of the commit semantic-release makes to publish CHANGELOG.md and package.json. It is a release artefact, not a change, so the notes leave it out; a range read by hand (a backfill, a re-run) contains the commits of earlier releases.
*/
export const releaseCommit = { type: 'chore', scope: 'release' } as const;

const sectionTitles = commitTypes.map((t) => t.title);
const revertTitle = commitTypes.find((t) => t.type === 'revert')?.title;

/** The fields of a commit parsed by conventional-commits-parser (plus the hash semantic-release adds) that the notes' transform reads. */
interface ParsedCommit {
readonly type?: string | null | undefined;
readonly scope?: string | null | undefined;
readonly subject?: string | null | undefined;
readonly hash?: string | null | undefined;
readonly revert: object | null;
readonly notes: readonly { readonly title: string; readonly text: string }[];
readonly references: readonly { readonly issue: string }[];
}

/** The part of the changelog context that links in a commit subject are built from. */
interface NotesContext {
readonly host?: string;
readonly owner?: string;
readonly repository?: string;
readonly repoUrl?: string;
}

/** A commit in the shape the angular preset's templates render. */
interface ListedCommit {
readonly notes: readonly { readonly title: string; readonly text: string }[];
readonly type: string;
readonly scope: string | null | undefined;
readonly shortHash: string | undefined;
readonly subject: string | undefined;
readonly references: readonly { readonly issue: string }[];
}

/** A group of commits under one section title, as the writer sorts them. */
interface CommitGroup {
readonly title: string;
}

const SHORT_HASH_LENGTH = 7;

/**
* Turns a subject's `#123` references into issue links. Returns the new subject and the issue numbers it linked, so the caller can drop them from the references printed after the subject.
* @param subject - The commit subject.
* @param context - The changelog context that names the repository.
* @returns The subject with issue links, and the linked issue numbers.
*/
function linkIssues(subject: string, context: NotesContext): { readonly subject: string; readonly issues: readonly string[] } {
const repositoryUrl = context.repository === undefined ? context.repoUrl : `${context.host ?? ''}/${context.owner ?? ''}/${context.repository}`;
if (repositoryUrl === undefined || repositoryUrl === '') {
return { subject, issues: [] };
}
const issues: string[] = [];
const linked = subject.replace(/#([0-9]+)/g, (_match, issue: string) => {
issues.push(issue);

return `[#${issue}](${repositoryUrl}/issues/${issue})`;
});

return { subject: linked, issues };
}

/**
* Turns a subject's `@user` mentions into profile links, leaving code spans and `@scope/name` package names alone.
* @param subject - The commit subject.
* @param host - The repository host, such as `https://github.com`.
* @returns The subject with mention links.
*/
function linkUsers(subject: string, host: string): string {
return subject.replace(/`[^`]*`|\B@([a-z0-9](?:-?[a-z0-9/]){0,38})/g, (match, username: string | undefined) => {
if (username === undefined) {
return match;
}

return username.includes('/') ? `@${username}` : `[@${username}](${host}/${username})`;
});
}

/**
* Decides which commits reach the release notes and under which section. It replaces the transform of the `angular` preset, which returns `undefined` for every type but feat, fix, perf and revert (and for any commit without a breaking change), so a release made of other types had an empty body. Here every type in `commitTypes` is listed under its title, and only release commits and commits that are not conventional commits are left out. The returned shape, the issue and user links and the short hash follow the preset's own transform (conventional-changelog-angular, src/writer.js), because the preset's templates render them.
* @param commit - The commit as parsed by conventional-commits-parser.
* @param context - The changelog context built by the release-notes generator.
* @returns The commit as the preset's templates expect it, or `undefined` when it is not listed.
*/
export function transformCommit(commit: ParsedCommit, context: NotesContext): ListedCommit | undefined {
if (commit.type === releaseCommit.type && commit.scope === releaseCommit.scope) {
return undefined;
}
const title = commit.revert === null ? commitTypes.find((t) => t.type === commit.type)?.title : revertTitle;
if (title === undefined) {
return undefined;
}

let subject: string | undefined;
let issues: readonly string[] = [];
if (typeof commit.subject === 'string') {
({ subject, issues } = linkIssues(commit.subject, context));
if (context.host !== undefined) {
subject = linkUsers(subject, context.host);
}
}

return {
notes: commit.notes.map((note) => ({ ...note, title: 'BREAKING CHANGES' })),
type: title,
scope: commit.scope === '*' ? '' : commit.scope,
shortHash: commit.hash?.substring(0, SHORT_HASH_LENGTH),
subject,
// References that already appear as a link in the subject are not repeated after it.
references: commit.references.filter((reference) => !issues.includes(reference.issue)),
};
}

/**
* Orders the sections of a release's notes as `commitTypes` does, instead of alphabetically as the angular preset does.
* @param a - A commit group titled with one of the `commitTypes` titles.
* @param b - The group compared with it.
* @returns Negative when `a` is listed before `b`.
*/
export function compareSections(a: CommitGroup, b: CommitGroup): number {
return sectionTitles.indexOf(a.title) - sectionTitles.indexOf(b.title);
}

/**
* Runs on `main`. Analyses commits since the last tag, bumps the version, publishes to npmjs.org (trusted OIDC publishing, no stored token; see .github/workflows/ci.yml), creates a versioned tag and GitHub Release with generated notes, and commits CHANGELOG.md + package.json back to main.
*/
const config: Options = {
branches: ['main'],
Expand All @@ -42,8 +170,15 @@ const config: Options = {
[
'@semantic-release/release-notes-generator',
{
// Deliberately angular, not conventionalcommits -- see documents.js's identical note: conventional-changelog-writer's bundled commit partial doesn't match the conventionalcommits preset's function-based partial signature, producing a changelog with a version header and nothing under it.
// The angular preset's layout (templates, heading levels, link style) with its transform replaced, so every type in commitTypes is listed. Not the conventionalcommits preset: its templates are functions, which the conventional-changelog-writer that @semantic-release/release-notes-generator bundles does not call, so a changelog built from it has a version header and section titles with no commits under them (checked against the installed releases of both).
preset: 'angular',
// The header patterns of the conventionalcommits preset that commit-analyzer above uses, so a commit the analyser reads as breaking (`feat!:`) is read the same here. The angular preset's own pattern does not accept `!`, which would drop such a commit from the notes.
parserOpts: {
headerPattern: /^(\w*)(?:\((.*)\))?!?: (.*)$/,
breakingHeaderPattern: /^(\w*)(?:\((.*)\))?!: (.*)$/,
noteKeywords: ['BREAKING CHANGE', 'BREAKING-CHANGE'],
},
writerOpts: { transform: transformCommit, commitGroupsSort: compareSections },
},
],
'@semantic-release/changelog',
Expand All @@ -53,7 +188,7 @@ const config: Options = {
'@semantic-release/git',
{
assets: ['CHANGELOG.md', 'package.json'],
message: 'chore(release): ${nextRelease.version} [skip ci]',
message: `${releaseCommit.type}(${releaseCommit.scope}): \${nextRelease.version} [skip ci]`,
},
],
],
Expand Down
Loading