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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ jobs:
workspaces: crates/codevetter-core
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
# Fails in a second on version drift. `core:qualify-cli` catches the same
# mistake, but only after lint, code health, the Astro build, and a full
# Rust companion build — ~3 minutes in, behind a message about the CLI
# rather than about the bump that caused it.
- name: Require one version across every shipped surface
run: pnpm version:check
- name: Lint
run: pnpm lint
- name: Fetch code-health base
Expand All @@ -102,6 +108,7 @@ jobs:
run: |
pnpm test:automation
pnpm test:release-manifest
pnpm test:version-sync
pnpm test:corpus-contracts
pnpm test:retrieval
pnpm test:core-tools
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/native-production-qualification.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ jobs:
- name: Install locked dependencies
run: pnpm install --frozen-lockfile --ignore-scripts

# Closes the gap `72b6b60b` named and left open: `core:qualify-cli` runs
# only in ci.yml, so before this step a drifted cut could sign, notarize,
# and publish an app whose bundled codevetter and codevetter-mcp reported
# the previous version. Runs before signing so drift costs seconds, not a
# full notarization round trip.
- name: Require one version across every shipped surface
run: pnpm version:check

- name: Require protected production inputs
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
Expand Down
13 changes: 13 additions & 0 deletions PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ Internal (fleet):

## Timeline

- **2026-09-06 — One version, enforced in both pipelines:** `MARKETING_VERSION`
in `Shared.xcconfig` is now the single source of truth. `pnpm version:sync`
derives `CURRENT_PROJECT_VERSION`, the three crate versions, the `=<version>`
pins between them, and `Cargo.lock` from it, and `pnpm version:check` fails
closed on drift. Cutting a release is again a one-line edit. This drift has
bitten three times — 1.13.0 (hand-fixed in `72b6b60b`, cut forward as 1.13.1)
and 1.13.4, where #266 bumped the xcconfig alone and left the crates at
1.13.3. The check runs in `ci.yml` *and* in the native qualification job,
which closes the gap `72b6b60b` recorded and left open: `core:qualify-cli`
ran only in CI, so a drifted cut could sign, notarize, and publish an app
whose bundled `codevetter` and `codevetter-mcp` reported the previous
version. Red CI was the lucky symptom; mismatched companions in a signed
build was the real exposure.
- **2026-09-06 — First release cut through the gated pipeline (1.13.3):** a
patch bump that carries no product change; its purpose is to exercise the
release gate landed in #259 end to end, which no run has ever done. The
Expand Down
6 changes: 3 additions & 3 deletions crates/codevetter-core/Cargo.lock

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

4 changes: 2 additions & 2 deletions crates/codevetter-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "codevetter-core"
version = "1.13.3"
version = "1.13.4"
edition = "2021"
publish = false
description = "CodeVetter verification core, CLI, and read-only MCP server"
Expand All @@ -24,7 +24,7 @@ path = "src/bin/codevetter-graph.rs"
[dependencies]
# Source-compatible command facade while legacy annotations are renamed. This
# is repository-owned and contains no Tauri, WebView, or windowing runtime.
tauri = { package = "codevetter-transport", version = "=1.13.3", path = "../codevetter-transport" }
tauri = { package = "codevetter-transport", version = "=1.13.4", path = "../codevetter-transport" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
Expand Down
2 changes: 1 addition & 1 deletion crates/codevetter-transport-macros/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "codevetter-transport-macros"
version = "1.13.3"
version = "1.13.4"
edition = "2021"
publish = false

Expand Down
4 changes: 2 additions & 2 deletions crates/codevetter-transport/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
[package]
name = "codevetter-transport"
version = "1.13.3"
version = "1.13.4"
edition = "2021"
publish = false

[lib]
name = "tauri"

[dependencies]
codevetter-transport-macros = { version = "=1.13.3", path = "../codevetter-transport-macros" }
codevetter-transport-macros = { version = "=1.13.4", path = "../codevetter-transport-macros" }
tokio = { version = "1", features = ["rt-multi-thread"] }
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"native:release:inspect": "node scripts/inspect-native-release-readiness.mjs",
"native:appcast:inspect": "node scripts/inspect-native-appcast.mjs",
"release:manifest:verify": "node scripts/verify-release-manifest.mjs",
"version:sync": "node scripts/sync-native-version.mjs",
"version:check": "node scripts/sync-native-version.mjs --check",
"native:notarization:prove": "node scripts/create-native-notarization-proof.mjs",
"native:package:finalize": "node scripts/finalize-native-package-archives.mjs",
"native:review:render": "node scripts/render-native-owner-review.mjs render",
Expand Down Expand Up @@ -91,6 +93,7 @@
"test:native-release": "node --test scripts/inspect-native-release-readiness.test.mjs",
"test:native-appcast": "node --test scripts/inspect-native-appcast.test.mjs",
"test:release-manifest": "node --test scripts/verify-release-manifest.test.mjs",
"test:version-sync": "node --test scripts/sync-native-version.test.mjs",
"test:native-notarization": "node --test scripts/create-native-notarization-proof.test.mjs",
"test:native-package-finalize": "node --test scripts/finalize-native-package-archives.test.mjs",
"test:native-runtime-compare": "node --test scripts/compare-desktop-runtime.test.mjs",
Expand Down
192 changes: 192 additions & 0 deletions scripts/sync-native-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env node
// Make MARKETING_VERSION the single source of truth for the shipped version.
//
// The version used to live in four hand-edited places: Shared.xcconfig, three
// crate manifests, the `=<version>` pins between those crates, and Cargo.lock.
// A cut that touched only Shared.xcconfig left the Rust companions behind, and
// that has now happened three times — 1.13.0 (fixed by hand in `72b6b60b`) and
// again in 1.13.4, where PR #266 bumped the xcconfig alone.
//
// The failure is worse than red CI. `core:qualify-cli` compares the built CLI's
// `--version` against MARKETING_VERSION, but it runs only in `ci.yml`, never in
// the release qualification path — a gap `72b6b60b` documented and left open.
// So a drifted cut can pass qualification and publish a signed, notarized app
// whose bundled `codevetter` and `codevetter-mcp` report the previous version.
//
// `--check` is the gate that closes that gap; the default mode is the writer
// that makes a version bump a one-line edit to Shared.xcconfig.

import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

import { isMainModule } from './native-script-utils.mjs';

const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');

/** The crates that ship inside the app bundle and must move with it. */
export const syncedCrates = [
'codevetter-core',
'codevetter-transport',
'codevetter-transport-macros',
];

const configPath = join(repositoryRoot, 'apps/macos/Config/Shared.xcconfig');
const lockPath = join(repositoryRoot, 'crates/codevetter-core/Cargo.lock');

const marketingVersionPattern = /^(MARKETING_VERSION\s*=\s*)(\S+)[^\S\n]*$/m;
const projectVersionPattern = /^(CURRENT_PROJECT_VERSION\s*=\s*)(\S+)[^\S\n]*$/m;
const semanticVersionPattern = /^(\d+)\.(\d+)\.(\d+)$/;

export function readMarketingVersion(source) {
const match = source.match(marketingVersionPattern);
if (!match) throw new Error('Shared.xcconfig has no MARKETING_VERSION');
const version = match[2];
if (!semanticVersionPattern.test(version)) {
throw new Error(`MARKETING_VERSION is not major.minor.patch: ${version}`);
}
return version;
}

/**
* Xcode wants a monotonic integer build number. Every release since 1.11.1 has
* encoded it as major*10000 + minor*100 + patch, so it is derived, not tracked.
*/
export function buildNumberFor(version) {
const match = version.match(semanticVersionPattern);
if (!match) throw new Error(`Not a major.minor.patch version: ${version}`);
const [, major, minor, patch] = match.map(Number);
if (minor > 99 || patch > 99) {
throw new Error(`Version overflows the build-number encoding: ${version}`);
}
return String(major * 10000 + minor * 100 + patch);
}

export function applyProjectVersion(source, version) {
if (!projectVersionPattern.test(source)) {
throw new Error('Shared.xcconfig has no CURRENT_PROJECT_VERSION');
}
return source.replace(projectVersionPattern, `$1${buildNumberFor(version)}`);
}

/**
* Rewrite the `[package]` version only. A crate manifest is full of unrelated
* `version = "..."` keys (every dependency has one), so anchor on the section
* header and stop at the next one.
*/
export function applyPackageVersion(manifest, version) {
const section = /(^\[package\]\n(?:(?!^\[)[\s\S])*?^version\s*=\s*)"[^"]*"/m;
if (!section.test(manifest)) throw new Error('Manifest has no [package] version');
return manifest.replace(section, `$1"${version}"`);
}

/**
* Rewrite the exact `=<version>` pins between our own crates. Keyed on the
* sibling `path = "../codevetter-*"`, so third-party exact pins (rmcp, for one)
* are left alone.
*/
export function applyWorkspacePins(manifest, version) {
return manifest.replace(
/\{[^{}\n]*path\s*=\s*"\.\.\/codevetter-[^"]*"[^{}\n]*\}/g,
(dependency) => dependency.replace(/(version\s*=\s*)"=[^"]*"/, `$1"=${version}"`)
);
}

/**
* Rewrite the `[[package]]` blocks Cargo.lock keeps for our own crates. Every
* transitive dependency has an identically shaped block, so match on the name.
*/
export function applyLockVersions(lockfile, version, crates = syncedCrates) {
let updated = lockfile;
for (const crate of crates) {
const block = new RegExp(
`(^\\[\\[package\\]\\]\\nname = "${crate}"\\nversion\\s*=\\s*)"[^"]*"`,
'm'
);
if (!block.test(updated)) throw new Error(`Cargo.lock has no entry for ${crate}`);
updated = updated.replace(block, `$1"${version}"`);
}
return updated;
}

function manifestPathFor(crate) {
return join(repositoryRoot, 'crates', crate, 'Cargo.toml');
}

/**
* Compute every file's desired content from MARKETING_VERSION. Pure against the
* filesystem read, so `--check` and the writer cannot disagree about the target.
*/
export function planVersionSync({ config, manifests, lockfile }) {
const version = readMarketingVersion(config);
const files = [
{
path: configPath,
label: 'apps/macos/Config/Shared.xcconfig',
current: config,
desired: applyProjectVersion(config, version),
},
];
for (const crate of syncedCrates) {
const current = manifests[crate];
files.push({
path: manifestPathFor(crate),
label: `crates/${crate}/Cargo.toml`,
current,
desired: applyWorkspacePins(applyPackageVersion(current, version), version),
});
}
files.push({
path: lockPath,
label: 'crates/codevetter-core/Cargo.lock',
current: lockfile,
desired: applyLockVersions(lockfile, version),
});

return { version, files, drifted: files.filter((file) => file.current !== file.desired) };
}

function readRepository() {
const manifests = {};
for (const crate of syncedCrates) manifests[crate] = readFileSync(manifestPathFor(crate), 'utf8');
return {
config: readFileSync(configPath, 'utf8'),
manifests,
lockfile: readFileSync(lockPath, 'utf8'),
};
}

export function syncNativeVersion({ check = false } = {}) {
const plan = planVersionSync(readRepository());

if (plan.drifted.length === 0) {
process.stdout.write(`Version ${plan.version} is consistent across every shipped surface.\n`);
return plan;
}

if (check) {
process.stdout.write(
`::error::MARKETING_VERSION is ${plan.version} but these files still carry an older ` +
`version: ${plan.drifted.map((file) => file.label).join(', ')}. ` +
`Run \`pnpm version:sync\` and commit the result.\n`
);
process.exitCode = 1;
return plan;
}

for (const file of plan.drifted) writeFileSync(file.path, file.desired);
process.stdout.write(
`Synced ${plan.drifted.length} file(s) to ${plan.version}:\n` +
plan.drifted.map((file) => ` ${file.label}\n`).join('')
);
return plan;
}

if (isMainModule(import.meta.url)) {
try {
syncNativeVersion({ check: process.argv.slice(2).includes('--check') });
} catch (error) {
process.stderr.write(`native version sync failed: ${error.message}\n`);
process.exitCode = 2;
}
}
Loading
Loading