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
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@
*.yml text eol=lf
*.yaml text eol=lf

# The changelog is append-only and almost every pull request adds an entry to
# the same short `[Unreleased]` list, so two open pull requests edit the same
# lines. A plain merge then conflicts, and the losing pull request has to have
# `develop` merged in by hand before it can pass the required status checks.
#
# `union` resolves that automatically by keeping both sides' additions, which
# is the correct merge for a file nobody edits in place. A genuine duplicate is
# still caught: the changelog validator's CHK_UNIQUE_CONTENT rule flags entries
# more than 90% similar, and
# `scripts/validation/__tests__/changelog-merge-strategy.test.js` fails if this
# line is removed or changed.
CHANGELOG.md merge=union

# Treat images and fonts as binary
*.png binary
*.jpg binary
Expand Down
26 changes: 25 additions & 1 deletion .github/mergify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,31 @@ pull_request_rules:
#
# Mergify skips pull requests in a conflict or already in the merge queue
# (`queue-position = -1`), so one needing manual conflict resolution still
# reports DIRTY, and a queued one is left to the queue.
# reports DIRTY, and a queued one is left to the queue. Mergify applies the
# same no-conflict requirement itself, independent of `-conflict` here, so
# that condition is belt-and-braces rather than the only guard.
#
# A pull request that is behind *and* conflicting is the one case this rule
# cannot help, and the `update` check stays red until someone resolves it by
# hand. Two separate things are going on, and it is worth keeping them apart:
#
# 1. `update` merges the base in, and merging cannot resolve a conflict, so
# this rule has nothing to contribute once the pull request is already
# classified as conflicting. It also declines earlier than that: it asks
# the API whether the pull request is mergeable, so on a conflicting one
# it reports "nothing to do" without attempting a merge.
# 2. What the human then has to do used to be resolve a CHANGELOG.md
# conflict by hand, because the changelog gate requires an `[Unreleased]`
# entry on nearly every pull request and every entry lands in the same
# short list (#3574). `.gitattributes` now marks that file `merge=union`,
# so a git merge keeps both sides' additions and that step is just a
# merge and a push.
#
# Step 2 only helps merges git performs. It does not make this rule able to
# update a conflicting pull request, and the red check still appears in that
# state — #3574 tracks that separately. `changelog-merge-strategy.test.js`
# covers the git merge and the attribute; nothing here tests the automated
# update, because the automated update does not consult `.gitattributes`.
#
# Cost: develop's ruleset sets `dismiss_stale_reviews_on_push`, so this
# action dismisses existing approvals on the pull request it touches. That is
Expand Down
16 changes: 13 additions & 3 deletions .github/validation/changelog/lib/compliance-checker.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ class ComplianceChecker {
};
}

const similarities = this.findSimilarEntries(entry.content, existingEntries, 0.9);
const similarities = this.findSimilarEntries(entry.content, existingEntries, 0.9, entry);

const passed = similarities.length === 0;

Expand All @@ -269,11 +269,21 @@ class ComplianceChecker {
};
}

findSimilarEntries(content, allEntries, threshold = 0.9) {
findSimilarEntries(content, allEntries, threshold = 0.9, currentEntry = null) {
const similar = [];

for (const otherEntry of allEntries) {
if (otherEntry.id === content || otherEntry.content === content) {
// Skip the entry being validated, by identity rather than by content.
// Skipping on equal content meant an entry that was byte-identical to a
// different entry was never compared, so exact duplicates passed
// CHK_UNIQUE_CONTENT unnoticed. That mattered once CHANGELOG.md became
// union-merged (#3574), where combining two identical entries is the
// most likely way to produce one.
//
// `otherEntry.id === content` used to sit here as well. Entry ids are
// `unreleased-N` (see lib/parser.js), so that comparison could never
// match and was dead code.
if (currentEntry && otherEntry === currentEntry) {
continue;
}

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Changelog Merges No Longer Conflict** — The changelog file is union-merged, so a git merge of two branches that each add an entry keeps both instead of conflicting. (#3574)
- **Issue Types Matched by Signal Strength** — A title naming a subject now classifies by that subject, so a test or compatibility update is no longer filed as a feature. ([#3577](https://github.com/lightspeedwp/.github/issues/3577))
- **Malformed Title Prefixes Repaired** — A title that already carries a recognised prefix but no space after the colon now has the space added, instead of gaining a second prefix. ([#3578](https://github.com/lightspeedwp/.github/issues/3578))
- **Wiring Guard Stops Blaming Healthy Tests** — The test wiring guard now reports an incomplete test listing as such, instead of naming suites that are in fact discovered. ([#3575](https://github.com/lightspeedwp/.github/issues/3575))
Expand Down
137 changes: 137 additions & 0 deletions scripts/validation/__tests__/changelog-merge-strategy.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');

const repositoryRoot = path.resolve(__dirname, '../../..');
const gitattributesPath = path.join(repositoryRoot, '.gitattributes');

const temporaryDirectories = [];

afterAll(() => {
temporaryDirectories.forEach((directory) => {
fs.rmSync(directory, { force: true, recursive: true });
});
});

function git(cwd, args) {
return spawnSync('git', args, {
cwd,
encoding: 'utf8',
env: {
...process.env,
GIT_AUTHOR_NAME: 'merge-strategy-test',
GIT_AUTHOR_EMAIL: 'test@example.invalid',
GIT_COMMITTER_NAME: 'merge-strategy-test',
GIT_COMMITTER_EMAIL: 'test@example.invalid',
},
});
}

function addUnreleasedEntry(directory, label) {
const file = path.join(directory, 'CHANGELOG.md');
const contents = fs.readFileSync(file, 'utf8');

fs.writeFileSync(
file,
contents.replace(
'## [Unreleased]\n',
`## [Unreleased]\n\n- **${label}** — a distinct entry added by this test. (#9001)\n`
)
);
}

/**
* A self-contained repository with the same union attribute the real one
* declares, so the merge is exercised through git rather than asserted on
* the text of .gitattributes. Pass `union: false` to reproduce the state
* before the attribute existed, which is what makes the pair of tests prove
* the attribute is the cause rather than merely present.
*/
function buildFixtureRepository({ union = true } = {}) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'changelog-merge-'));

temporaryDirectories.push(directory);

expect(git(directory, ['init', '--quiet', '--initial-branch=main']).status).toBe(0);
fs.writeFileSync(path.join(directory, 'CHANGELOG.md'), '## [Unreleased]\n\n### Added\n');

const attributes = fs
.readFileSync(gitattributesPath, 'utf8')
.split('\n')
.filter((line) => !/^CHANGELOG\.md\s+merge=union$/.test(line))
.join('\n');

fs.writeFileSync(
path.join(directory, '.gitattributes'),
union ? fs.readFileSync(gitattributesPath, 'utf8') : attributes
);
expect(git(directory, ['add', '.']).status).toBe(0);
expect(git(directory, ['commit', '--quiet', '-m', 'base']).status).toBe(0);

const base = git(directory, ['rev-parse', 'HEAD']).stdout.trim();

addUnreleasedEntry(directory, 'Entry contributed by the first branch');
expect(git(directory, ['commit', '--quiet', '-am', 'first branch']).status).toBe(0);
const first = git(directory, ['rev-parse', 'HEAD']).stdout.trim();

git(directory, ['checkout', '--quiet', base]);
addUnreleasedEntry(directory, 'Entry contributed by the second branch');
expect(git(directory, ['commit', '--quiet', '-am', 'second branch']).status).toBe(0);
const second = git(directory, ['rev-parse', 'HEAD']).stdout.trim();

return { directory, first, second };
}

describe('changelog merge strategy (#3574)', () => {
test('CHANGELOG.md is declared as union-merged', () => {
const attributes = fs.readFileSync(gitattributesPath, 'utf8');

expect(attributes).toMatch(/^CHANGELOG\.md\s+merge=union$/m);
});

test('git resolves that attribute for the changelog in this repository', () => {
const result = git(repositoryRoot, ['check-attr', 'merge', '--', 'CHANGELOG.md']);

expect(result.status).toBe(0);
expect(result.stdout).toMatch(/^CHANGELOG\.md:\s+merge:\s+union$/m);
});

// The regression this guards: two open pull requests each add an entry to
// the same `[Unreleased]` list, which a plain merge turns into a conflict.
// The pull request that lands second is then stuck behind develop until
// someone merges it in by hand, and Mergify's `update` action reports a
// permanent red check because it cannot resolve a conflict.
test('two branches adding different entries merge without conflict and keep both', () => {
const { directory, first, second } = buildFixtureRepository();
const merge = git(directory, ['merge', '--no-edit', first]);

expect(merge.stdout + merge.stderr).not.toMatch(/CONFLICT/);

const merged = fs.readFileSync(path.join(directory, 'CHANGELOG.md'), 'utf8');

expect(merged).toContain('Entry contributed by the first branch');
expect(merged).toContain('Entry contributed by the second branch');
expect(merged).not.toMatch(/^<{7}/m);
expect(git(directory, ['rev-parse', '--verify', '--quiet', 'MERGE_HEAD']).status).not.toBe(0);
expect(git(directory, ['rev-parse', 'HEAD^']).stdout.trim()).toBe(second);
});

// The baseline this fixes. Without the attribute, the same two branches
// conflict — so the passing test above demonstrates the attribute is the
// cause of the difference, not an incidental property of the fixture.
test('the same two branches conflict once the attribute is removed', () => {
const { directory, first } = buildFixtureRepository({ union: false });
const merge = git(directory, ['merge', '--no-edit', first]);

expect(merge.stdout + merge.stderr).toMatch(/CONFLICT/);
expect(fs.readFileSync(path.join(directory, 'CHANGELOG.md'), 'utf8')).toMatch(/^<{7}/m);
});

// A genuine duplicate still has to be caught somewhere, because union can
// combine two identical entries. It is caught by the changelog validator's
// CHK_UNIQUE_CONTENT rule, which flags entries more than 90% similar. That
// guarantee is proved behaviourally, against the rule implementation, in
// `changelog-unique-content.test.js` — an earlier version of this file only
// asserted the rule was declared, which proved nothing about its behaviour.
});
79 changes: 79 additions & 0 deletions scripts/validation/__tests__/changelog-unique-content.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
const path = require('node:path');

const ComplianceChecker = require(
path.resolve(__dirname, '../../../.github/validation/changelog/lib/compliance-checker.js')
).default;

function entry(id, content) {
return { id, content };
}

describe('CHK_UNIQUE_CONTENT (#3574)', () => {
let checker;

beforeEach(() => {
checker = new ComplianceChecker();
});

// This rule is the safety net for CHANGELOG.md being union-merged: if two
// pull requests contribute the same entry, git keeps both and something has
// to report it. It did not, because findSimilarEntries skipped every entry
// whose content equalled the one being checked, which is also what an exact
// duplicate looks like.
it('reports an entry that duplicates another entry exactly', async () => {
const content = 'Fixed the metrics duration assertion so it stops flaking under load. (#3572)';
const entries = [entry('unreleased-0', content), entry('unreleased-1', content)];

const result = await checker.checkUniqueContent(entries[1], { allEntries: entries });

expect(result.passed).toBe(false);
expect(result.details.count).toBe(1);
expect(result.details.similarEntries[0].content).toBe(content);
});

it('reports near-identical entries above the similarity threshold', async () => {
const entries = [
entry('unreleased-0', 'Fixed the metrics duration assertion so it stops flaking. (#3572)'),
entry('unreleased-1', 'Fixed the metrics duration assertion so it stops flaking! (#3572)'),
];

const result = await checker.checkUniqueContent(entries[1], { allEntries: entries });

expect(result.passed).toBe(false);
});

it('does not report an entry as similar to itself', async () => {
const entries = [
entry('unreleased-0', 'Added a workflow reachability guard. (#3570)'),
entry('unreleased-1', 'Documented the composite action contracts. (#3570)'),
];

for (const candidate of entries) {
const result = await checker.checkUniqueContent(candidate, { allEntries: entries });

expect(result.passed).toBe(true);
}
});

it('passes when there is nothing to compare against', async () => {
const result = await checker.checkUniqueContent(entry('unreleased-0', 'Anything.'), {
allEntries: [],
});

expect(result.passed).toBe(true);
});

it('treats three copies as two reported duplicates for the entry under test', async () => {
const content = 'Stabilised two timing assertions. (#3572)';
const entries = [
entry('unreleased-0', content),
entry('unreleased-1', content),
entry('unreleased-2', content),
];

const result = await checker.checkUniqueContent(entries[2], { allEntries: entries });

expect(result.passed).toBe(false);
expect(result.details.count).toBe(2);
});
});
Loading