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
22 changes: 18 additions & 4 deletions src/lib/release-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,14 @@ function compareVersions(a, b) {
* Classify the relationship between main and staging using the final tree delta
* when available. Historical commit paths can include equivalent workflow or
* configuration changes that are no longer present in the branch delta, so the
* final trees are the source of truth for release-only reconciliation. Any
* staging-only commit still triggers a replay path so no staging-only work can
* be silently discarded.
* final trees are the source of truth for release-only reconciliation. When
* directChangedPaths is supplied, unexpected paths in historical main-only
* commits are ignored only when they are absent from the final tree delta
* (their content is already identical in both branches); unexpected main-only
* paths that still differ between the final trees fail closed. Without
* directChangedPaths the historical check stays strict. Any staging-only
* commit still triggers a replay path so no staging-only work can be silently
* discarded.
* @param {{
* mainSha: string,
* stagingSha: string,
Expand Down Expand Up @@ -282,10 +287,19 @@ export function classifyReconciliation(input) {
}
}
}
// Historical main-only commits may list paths whose content is already
// identical in the final staging tree (for example an equivalent workflow
// change). When the final tree delta is available, such paths are not
// genuine content differences and are ignored unless they also appear in
// the direct tree delta; without directChangedPaths the strict historical
// check is preserved unchanged.
const directTreePaths = Array.isArray(directChangedPaths)
? new Set(directChangedPaths.map((path) => path.trim()).filter(Boolean))
: null
const unexpectedMain = unexpectedReleasePaths(
[...new Set(mainOnlyCommits.flatMap((commit) => commit.changedPaths || []))],
allowed,
)
).filter((path) => directTreePaths === null || directTreePaths.has(path))
if (unexpectedMain.length) {
return {
action: 'fail',
Expand Down
61 changes: 61 additions & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,67 @@ describe('code-foundry CLI', () => {
)
})

it('rebase-staging historical main-only paths absent from the final tree delta', () => {
const allowed = approvedReleaseFiles()
assert.deepEqual(
classifyReconciliation({
mainSha: 'main',
stagingSha: 'staging',
directChangedPaths: ['CHANGELOG.md', 'src/index.ts'],
mainOnlyCommits: [
{ sha: 'release-commit', changedPaths: ['CHANGELOG.md', 'package.json'] },
{ sha: 'historical-workflow', changedPaths: ['.github/workflows/release.yml'] },
],
stagingOnlyCommits: [{ sha: 'staging-feature', changedPaths: ['src/index.ts'] }],
allowed,
}),
{
action: 'rebase-staging',
targetSha: 'main',
mainOnly: ['release-commit', 'historical-workflow'],
stagingOnly: ['staging-feature'],
reason: 'staging contains unpromoted commits; replay them onto main.',
},
)
})

it('fails when a historical main-only unexpected path still differs in the final tree delta', () => {
const allowed = approvedReleaseFiles()
assert.deepEqual(
classifyReconciliation({
mainSha: 'main',
stagingSha: 'staging',
directChangedPaths: ['CHANGELOG.md', '.github/workflows/release.yml'],
mainOnlyCommits: [
{ sha: 'main-workflow', changedPaths: ['.github/workflows/release.yml'] },
],
stagingOnlyCommits: [{ sha: 'staging-feature', changedPaths: ['src/index.ts'] }],
allowed,
}),
{
action: 'fail',
reason: 'main contains commits that are not release metadata.',
unexpected: ['.github/workflows/release.yml'],
},
)
})

it('keeps historical main-only path rejection strict without directChangedPaths', () => {
const allowed = approvedReleaseFiles()
assert.equal(
classifyReconciliation({
mainSha: 'main',
stagingSha: 'staging',
mainOnlyCommits: [
{ sha: 'main-workflow', changedPaths: ['.github/workflows/release.yml'] },
],
stagingOnlyCommits: [{ sha: 'staging-feature', changedPaths: ['src/index.ts'] }],
allowed,
}).action,
'fail',
)
})

it('surfaces exact reconciliation failure reason from local classification', () => {
const { root, remote, run } = createReconcileWorkspace()
const commit = (message) => {
Expand Down
Loading