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
143 changes: 143 additions & 0 deletions Governance/policies/CHANGELOG_POLICY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Changelog Policy

## Purpose

This policy defines how TeachLink Web records changes in its changelog. It fixes
the format entries are written in, the categories a change may be filed under,
and the conditions under which an entry is required, so that a reader can tell
from the changelog alone what changed, how it affects them, and which release
carried it.

The changelog is the human-readable companion to the version numbers defined in
`Governance/policies/VERSIONING.md`. A version number says how large a change
is; the changelog says what the change was. Without a fixed format and a fixed
set of categories, entries drift into prose that is either too vague to act on
or too detailed to scan, and the document stops being worth reading.

## Scope

This policy applies to the changelog of the web client in this repository: the
`CHANGELOG.md` file at the repository root, and the release notes published with
each Git tag. It covers every change that a consumer of the project can observe:
user-facing capabilities and pages, URL routes, components and their public
props, exported modules and utilities, API query and response shapes the
frontend consumes, configuration and environment variables, build and CI entry
points, and governance documents.

It does not cover the commit history, which is governed by the pull request
conventions in `CONTRIBUTING.md`; the version numbers themselves, defined in
`Governance/policies/VERSIONING.md`; the deprecation notice periods and removal
criteria, defined in `Governance/policies/DEPRECATION.md`; or the release
checklist steps, defined in `Governance/templates/RELEASE_CHECKLIST.md`. A
changelog entry records that a change shipped; it does not replace the process
that produced it.

## Changelog Format

The changelog is a single Markdown file, `CHANGELOG.md`, at the repository root.
It is written for a reader who wants to know what changed, not for a reader who
wants to know how it was implemented.

- **One section per release.** Each released version gets a heading of the form
`## [MAJOR.MINOR.PATCH] - YYYY-MM-DD`, using the version format and date of
the release. The version matches the Git tag and the `version` field in the
root `package.json` exactly, without a `v` prefix.
- **Newest release first.** Releases are ordered from newest to oldest, so the
top of the file is always the current release.
- **Unreleased section on top.** Work that has merged but not yet been released
is collected under a `## [Unreleased]` heading above the newest release. The
section is emptied into the release section when the release is cut, never
deleted with its entries.
- **Categories within each release.** Each release section is divided into the
categories below, in the order they are listed. A category with no entries is
omitted rather than left empty.
- **One entry per change.** Each entry is a single line beginning with `- `,
written in the past tense, and describes the observable change rather than the
implementation. Entries name the affected surface (a page, route, component,
export, configuration key, or command) so a reader can find it.
- **References are linked.** Where an entry corresponds to an issue or pull
request, the entry ends with a link to it, so the reasoning behind the change
is one click away.
- **Breaking changes are called out.** An entry for a breaking change is marked
with a `**Breaking:**` prefix and states what a consumer must do to migrate,
or links to the migration guidance that does.
- **Security fixes are attributed.** A security fix is listed under `Security`
and follows the disclosure rules in
`Governance/processes/VULN_DISCLOSURE.md`; it never reveals an unpatched
defect or a reporter who asked not to be named.

The changelog is append-only in spirit: an entry for a released version is
corrected only to fix a factual error, and the correction is noted in the entry
rather than made silently.

## Change Categories

Every entry belongs to exactly one of the following categories, which are the
only categories the changelog uses:

- **Added** — new user-facing capabilities, pages, routes, components, props,
configuration options, environment variables, or exported modules.
- **Changed** — changes to existing behaviour that are not fixes and not
removals, including dependency upgrades that alter what users experience.
- **Deprecated** — interfaces that are still present but scheduled for removal
under `Governance/policies/DEPRECATION.md`, with the replacement and the
earliest removal release named.
- **Removed** — interfaces that have been removed, after any required
deprecation notice period has elapsed.
- **Fixed** — bug fixes that correct behaviour without changing an interface.
- **Security** — fixes and hardening that address a vulnerability or reduce
exposure, described without disclosing an unpatched defect.
- **Documentation** — changes to documentation, governance, or comments that do
not change behaviour.

A change that fits more than one category is filed under the category that
matters most to a reader: a security fix that also changes behaviour is
`Security`, and a removal that also fixes a bug is `Removed`. Categories are
never invented ad hoc; a change that seems to need a new category is discussed
through `Governance/processes/RFC_PROCESS.md` before the category is added.

## When Entries Are Required

An entry is required for every change that a consumer of the project can
observe, and it is written in the same pull request that makes the change, not
in a follow-up:

- **Always required.** Any change that adds, changes, deprecates, or removes a
user-facing capability, page, route, component, prop, export, configuration
key, environment variable, or build or CI entry point. Any bug fix, security
fix, or dependency upgrade that changes what users experience. Any change to a
governance document.
- **Required at release time.** A change that is merged behind a default-off
feature flag gets its entry when the flag is enabled for users, not when the
flag is added, so the changelog never announces behaviour users cannot reach.
- **Not required.** Internal refactors with no observable change, test-only
changes, comment and formatting fixes that change no behaviour, and
dependency bumps within an existing range that change nothing users
experience. These may still be listed under `Documentation` when they are
worth recording.
- **Never omitted for convenience.** A change that is required but missing its
entry is incomplete and is not merged. Reviewers check for the entry as part
of the normal review, and the release checklist verifies that the `Unreleased`
section is empty before a release is tagged.

When in doubt, an entry is written. A changelog entry that turns out to be
unnecessary costs a line; a missing entry costs a consumer the ability to know
what changed.

## Ownership and Review

- Maintainers own this policy, approve exceptions, and review the changelog for
format and category drift on a regular cadence.
- The contributor who makes a change writes its entry. Reviewers verify the
entry is present, correctly categorised, and written for a reader rather than
for the author.
- A change to this policy is proposed in a pull request that touches only the
`Governance/` folder, and the change is documented in the pull request
description.

## Success

This policy succeeds when the changelog is accurate enough that a consumer can
decide whether to take an update by reading it, every observable change appears
in the release that shipped it, and no entry requires the reader to open the
source to understand what changed.
208 changes: 208 additions & 0 deletions Governance/policies/CHANGELOG_POLICY.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* Regression tests for the Changelog Policy (Governance/policies/CHANGELOG_POLICY.md).
*
* The policy is documentation, but it makes concrete, enforceable guarantees:
* a fixed entry format, a closed list of change categories, and a defined set
* of conditions under which an entry is required. These tests pin those
* guarantees to the checked-in document so they cannot silently regress (for
* example, an edit that drops a category or waters down the "always required"
* rule fails CI), and they keep the document consistent with the rest of
* `Governance/`.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';

const POLICY_PATH = path.resolve(__dirname, 'CHANGELOG_POLICY.md');
const policy = readFileSync(POLICY_PATH, 'utf8');

/** Resolve a sibling governance document relative to this test file. */
function readGovernanceDoc(relativePath: string): string {
return readFileSync(path.resolve(__dirname, relativePath), 'utf8');
}

/** Strip Markdown syntax so keyword assertions match prose, not formatting. */
function plainProse(markdown: string): string {
return markdown
.replace(/`([^`]*)`/g, '$1') // inline code keeps its text
.replace(/\*\*([^*]*)\*\*/g, '$1') // bold keeps its text
.replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2') // links keep text and target
.replace(/\s+/g, ' ') // line wrapping must not affect prose matching
.toLowerCase();
}

const prose = plainProse(policy);

/** Every "## Heading" in the document, in order. */
const sections = [...policy.matchAll(/^## (.+)$/gm)].map((match) => match[1]);

/** Extract the body of a single "## Section" (text up to the next heading). */
function sectionBody(title: string): string {
const start = policy.indexOf(`## ${title}\n`);
expect(start, `section "${title}" is missing`).toBeGreaterThanOrEqual(0);
const next = policy.indexOf('\n## ', start + 1);
const body = next === -1 ? policy.slice(start) : policy.slice(start, next);
return plainProse(body);
}

describe('CHANGELOG policy document structure', () => {
it('is titled "Changelog Policy" like every governance document', () => {
expect(policy.startsWith('# Changelog Policy\n')).toBe(true);
});

it('keeps the canonical governance document sections', () => {
expect(sections).toEqual([
'Purpose',
'Scope',
'Changelog Format',
'Change Categories',
'When Entries Are Required',
'Ownership and Review',
'Success',
]);
});

it('covers the three areas the issue requires', () => {
expect(sections).toContain('Changelog Format');
expect(sections).toContain('Change Categories');
expect(sections).toContain('When Entries Are Required');
});

it('has no unresolved template placeholders', () => {
expect(policy).not.toMatch(/TBD|TODO|FIXME|<[a-z-]+>|XXX/);
});

it('stays within the house documentation line width (max 82 columns)', () => {
const longest = Math.max(...policy.split('\n').map((line) => line.length));
expect(longest).toBeLessThanOrEqual(82);
});
});

describe('changelog format guarantees', () => {
const body = sectionBody('Changelog Format');

it('names the changelog file and its location', () => {
expect(body).toContain('changelog.md');
expect(body).toContain('repository root');
});

it('requires one section per release with a version and date heading', () => {
expect(body).toContain('one section per release');
expect(body).toContain('major.minor.patch');
expect(body).toContain('yyyy-mm-dd');
});

it('requires an Unreleased section above the newest release', () => {
expect(body).toContain('unreleased');
expect(body).toContain('newest release first');
});

it('requires one single-line entry per change', () => {
expect(body).toContain('one entry per change');
expect(body).toContain('past tense');
});

it('requires breaking changes to be called out with migration guidance', () => {
expect(body).toContain('breaking');
expect(body).toContain('migrate');
});

it('keeps security entries consistent with the disclosure process', () => {
expect(body).toContain('security');
expect(body).toContain('vuln_disclosure.md');
});
});

describe('change category guarantees', () => {
const body = sectionBody('Change Categories');

it('defines the closed list of categories', () => {
for (const category of [
'added',
'changed',
'deprecated',
'removed',
'fixed',
'security',
'documentation',
]) {
expect(body, `category "${category}" is missing`).toContain(category);
}
});

it('states that these are the only categories the changelog uses', () => {
expect(body).toContain('only categories');
});

it('routes new categories through the RFC process', () => {
expect(body).toContain('rfc_process.md');
});

it('ties deprecation entries to the deprecation policy', () => {
expect(body).toContain('deprecation.md');
expect(body).toContain('replacement');
});
});

describe('when entries are required', () => {
const body = sectionBody('When Entries Are Required');

it('requires an entry for every observable change', () => {
expect(body).toContain('always required');
expect(body).toContain('user-facing');
});

it('requires the entry in the same pull request as the change', () => {
expect(body).toContain('same pull request');
});

it('defers flagged behaviour until the flag is enabled for users', () => {
expect(body).toContain('feature flag');
expect(body).toContain('enabled for users');
});

it('exempts internal refactors and test-only changes', () => {
expect(body).toContain('not required');
expect(body).toContain('internal refactors');
expect(body).toContain('test-only');
});

it('does not allow a required entry to be omitted', () => {
expect(body).toContain('never omitted for convenience');
expect(body).toContain('not merged');
});
});

describe('changelog policy consistency with the rest of Governance', () => {
it('references the versioning policy for version numbers', () => {
expect(prose).toContain('versioning.md');
});

it('references the deprecation policy for notice periods', () => {
expect(prose).toContain('deprecation.md');
});

it('references the release checklist for release-time verification', () => {
expect(prose).toContain('release_checklist.md');
});

it('references the vulnerability disclosure process for security entries', () => {
expect(prose).toContain('vuln_disclosure.md');
});

it('references the RFC process for adding a category', () => {
expect(prose).toContain('rfc_process.md');
});

it('points at documents that actually exist', () => {
for (const relativePath of [
'VERSIONING.md',
'DEPRECATION.md',
'../templates/RELEASE_CHECKLIST.md',
'../processes/VULN_DISCLOSURE.md',
'../processes/RFC_PROCESS.md',
]) {
expect(() => readGovernanceDoc(relativePath)).not.toThrow();
}
});
});
Loading