Skip to content
Closed
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
6 changes: 5 additions & 1 deletion .github/scripts/issue-triage-autoclose.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ function normalizeSignatureLine(raw) {
// case-bearing discriminators; the "exact signature" promise is only true
// if the comparison keeps them distinct.
return String(raw || "")
.replace(/<!--[\s\S]*?-->/g, " ")
// An UNCLOSED comment hides everything after it through EOF, exactly as the
// quality parser treats it. Matching only `<!-- ... -->` let hidden text
// become the "exact shared signature" that closes an issue: two reports
// whose visible bodies differ could match on text GitHub never renders.
.replace(/<!--[\s\S]*?(?:-->|$)/g, " ")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip comments before splitting the issue body

When an unclosed comment spans multiple lines, this replacement sees only one line at a time because extractStrongFailureSignatures splits the text afterward and calls normalizeSignatureLine separately for each line. For example, a body containing <!-- followed on the next line by a strong failure signature still returns that hidden signature, allowing two visibly different issues to be auto-closed as duplicates. Strip comments from the complete body through EOF before splitting it into candidate lines.

Useful? React with 👍 / 👎.

.replace(/^\s*(?:>|[-*+]\s+|\d+[.)]\s+)/, "")
.replace(/^[`~]{3,}[^\n]*$/, "")
// Backticks are unambiguous Markdown code delimiters. Preserve `_`, `~`,
Expand Down
36 changes: 27 additions & 9 deletions scripts/build-release-changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,25 @@ export function categoryForPull(pr: AssociatedPullRequest): string {
* Prerelease: newest prior release of either channel, so previews stay incremental.
* Stable: newest prior stable only, so the final changelog always reconstructs
* the complete stable-to-stable range and cannot lose prerelease changes.
*
* `isAncestor` filters to tags actually reachable from the target. Without it a
* preview could select the newest STABLE tag, which lives on `main` and is not
* reachable from `preview` — the ancestry guard then threw and no preview could
* be released at all once the stable lineage advanced. Selecting the newest
* REACHABLE release keeps previews incremental and keeps the range honest.
*/
export function selectReleaseBaseline(version: string, tags: string[]): string | null {
export function selectReleaseBaseline(
version: string,
tags: string[],
isAncestor?: (tag: string) => boolean,
): string | null {
const releaseTag = version.startsWith("v") ? version : `v${version}`;
const targetIsPrerelease = isPrereleaseVersion(version);
const candidates = tags
.map(tag => tag.trim())
.filter(tag => /^v\d/.test(tag) && compareReleaseTags(tag, releaseTag) < 0)
.filter(tag => targetIsPrerelease || !isPrereleaseVersion(tag))
.filter(tag => isAncestor === undefined || isAncestor(tag))
.sort(compareReleaseTags);
return candidates.length > 0 ? candidates[candidates.length - 1]! : null;
}
Expand Down Expand Up @@ -530,16 +541,23 @@ async function main(argv: string[]): Promise<void> {
const tags = (await commandText(["git", "tag", "--list", "v[0-9]*"]))
.split(/\r?\n/)
.filter(Boolean);
const baseline = selectReleaseBaseline(version, tags);
// Resolve reachability once per tag, then let selection skip anything not in
// this target's history. Previously selection ignored ancestry and the guard
// below threw, so a preview became unreleasable the moment the stable lineage
// moved ahead of it.
const ancestryCache = new Map<string, boolean>();
for (const tag of tags) {
const probe = await runCommand(["git", "merge-base", "--is-ancestor", tag, target]);
ancestryCache.set(tag, probe.exitCode === 0);
}
const baseline = selectReleaseBaseline(version, tags, tag => ancestryCache.get(tag) === true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Pass the reachable baseline into the renderer

When a preview branch cannot reach the newest stable tag, this correctly selects an older reachable baseline for GitHub-generated notes and releaseCommits, but buildReleaseNotes later calls selectReleaseBaseline(input.version, input.tags) without the reachability predicate. The published Full Changelog URL and reported baseline therefore still name the newer unreachable stable tag even though the entries were built from another range. Pass this selected baseline into buildReleaseNotes, or pass only the reachable tag set, so every stage uses the same range.

Useful? React with 👍 / 👎.

const releaseTag = version.startsWith("v") ? version : `v${version}`;

// Ancestry is required for BOTH channels. It used to be checked only for
// stable releases, which let a preview pick the newest tag from a diverged
// lineage: `git log baseline..target` then emitted the handful of commits
// unique to that unrelated branch and reported "commits=0" coverage, so a
// preview shipped notes that omitted its own history and named someone
// else's. Fail closed instead — a non-ancestral baseline cannot describe a
// range at all, whichever channel asked for it.
// Selection above already filters to reachable tags, so this is now a
// belt-and-braces assertion rather than the primary gate. It stays because a
// non-ancestral baseline cannot describe a range at all, and a future caller
// that skips the filter must still fail closed rather than emit notes drawn
// from an unrelated lineage.
if (baseline) {
const ancestry = await runCommand(["git", "merge-base", "--is-ancestor", baseline, target]);
if (ancestry.exitCode !== 0) {
Expand Down
17 changes: 17 additions & 0 deletions src/codex/log-guard/inspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,23 @@ function sameColumns(columns: ColumnRow[]): boolean {
});
}

/**
* The authoritative compatibility predicate: exact table SQL, exact column
* metadata, and every canonical index.
*
* Exported because the mutation paths must apply the SAME test inside their
* write transaction. They used to check column NAMES only, which is strictly
* weaker than what the inspector reports, so a schema change landing between
* the outer inspection and the locked write let Protect install a row-dropping
* trigger and let Reclaim vacuum pages on a database the inspector classifies
* as monitor-only. The lock serializes OpenCodex against itself; it does not
* stop Codex or another SQLite writer, so that TOCTOU window is real.
*/
export function hasCurrentLogsSchema(db: Database): boolean {
const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all();
return hasCurrentLogsTable(db, columns);
}

function hasCurrentLogsTable(db: Database, columns: ColumnRow[]): boolean {
const table = db.query<SchemaObjectRow, []>(
"SELECT name, type, sql FROM sqlite_schema WHERE name = 'logs' LIMIT 1",
Expand Down
10 changes: 5 additions & 5 deletions src/codex/log-guard/maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Database, constants as sqliteConstants } from "bun:sqlite";

import { getCodexHome, resolveCodexLogsDbPath } from "../paths";
import { samePathIdentity } from "../user-identity";
import { inspectCodexLogs } from "./inspect";
import { hasCurrentLogsSchema, inspectCodexLogs } from "./inspect";
import { withCodexLogGuardLock, type CodexLogGuardLockOutcome } from "./lock";
import { sameLogGuardPathIdentity } from "./path-safety";
import { isSqliteBusy } from "./sqlite-errors";
Expand Down Expand Up @@ -145,10 +145,10 @@ function databasePathStillMatches(
}

function exactCurrentSchema(db: Database): boolean {
const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all().map(row => row.name).sort();
const expected = [...CURRENT_LOG_COLUMNS].sort();
return columns.length === expected.length
&& columns.every((value, index) => value === expected[index]);
// Same reasoning as protection.ts: the pre-mutation recheck must match the
// inspector's compatibility contract exactly, or Reclaim can vacuum a
// database the inspector classifies as monitor-only.
return hasCurrentLogsSchema(db);
}

function pragmaNumber(db: Database, sql: string): number {
Expand Down
10 changes: 6 additions & 4 deletions src/codex/log-guard/protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url";
import { Database, constants as sqliteConstants } from "bun:sqlite";

import { getCodexHome, resolveCodexLogsDbPath } from "../paths";
import { inspectCodexLogs, type CodexLogGuardInspection } from "./inspect";
import { hasCurrentLogsSchema, inspectCodexLogs, type CodexLogGuardInspection } from "./inspect";
import { withCodexLogGuardLock, type CodexLogGuardLockOutcome } from "./lock";
import { sameLogGuardPathIdentity } from "./path-safety";
import { isSqliteBusy } from "./sqlite-errors";
Expand Down Expand Up @@ -193,9 +193,11 @@ function observeTriggers(db: Database): CodexLogGuardObservedMode {
}

function exactCurrentSchema(db: Database): boolean {
const columns = db.query<ColumnRow, []>("PRAGMA table_info(logs)").all().map(row => row.name).sort();
const expected = [...CURRENT_LOG_COLUMNS].sort();
return columns.length === expected.length && columns.every((value, index) => value === expected[index]);
// Delegates to the inspector's predicate so the locked recheck is exactly as
// strict as the compatibility report. Column names alone let a schema change
// between inspection and the locked write slip a mutation onto a database the
// inspector calls monitor-only.
return hasCurrentLogsSchema(db);
}

/**
Expand Down
23 changes: 23 additions & 0 deletions tests/build-release-changelog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ const generatedBugFix = [
].join("\n");

describe("selectReleaseBaseline", () => {
test("skips a newer release that is not reachable from the target", () => {
// A preview lives on its own lineage. Selecting the newest tag regardless of
// reachability picked a stable tag on main, the ancestry guard then threw,
// and no preview could be released once the stable lineage moved ahead.
// Reproduced against the real repository: without the filter the preview
// range selected v2.21.0, which is an ancestor of neither preview nor dev.
const tags = ["v2.17.0", "v2.19.0", "v2.21.0"];
const reachable = new Set(["v2.17.0", "v2.19.0"]);

expect(selectReleaseBaseline("2.22.0-preview.1", tags)).toBe("v2.21.0");
expect(selectReleaseBaseline("2.22.0-preview.1", tags, tag => reachable.has(tag))).toBe("v2.19.0");
});

test("the reachability filter applies to stable releases too", () => {
const tags = ["v2.17.0", "v2.19.0", "v2.21.0"];
const reachable = new Set(["v2.17.0"]);
expect(selectReleaseBaseline("2.22.0", tags, tag => reachable.has(tag))).toBe("v2.17.0");
});

test("an absent filter preserves the previous selection behavior", () => {
const tags = ["v2.17.0", "v2.19.0"];
expect(selectReleaseBaseline("2.20.0", tags)).toBe("v2.19.0");
});
test("preview releases are incremental from the previous release", () => {
expect(selectReleaseBaseline("2.0.0-preview.2", [
"v1.0.0",
Expand Down
18 changes: 18 additions & 0 deletions tests/codex-log-guard-protection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,4 +327,22 @@ describe("Codex Log Guard protection", () => {
expect(unprotectCodexLogs(deps).ok).toBe(true);
expect(triggers(databasePath)).toEqual([]);
});
test("a schema change between inspection and the locked write is refused", () => {
// TOCTOU: the lock serializes OpenCodex against itself, not against Codex or
// another SQLite writer. The locked recheck used to compare column NAMES
// only - strictly weaker than the inspector's contract - so a migration
// landing in that window let Protect install a row-dropping trigger on a
// database the inspector classifies as monitor-only.
const { codexHome, databasePath } = fixture();
const deps = testDeps(codexHome);

// Drop a canonical index the inspector requires but a column-name check
// cannot see.
const db = new Database(databasePath);
db.exec("DROP INDEX idx_logs_ts");
db.close();

expect(protectCodexLogs("quiet", deps)).toEqual({ ok: false, error: "unsupported_schema" });
expect(triggers(databasePath)).toEqual([]);
});
});
Loading