Skip to content

Commit d8492e4

Browse files
committed
ci: fail closed on identity freeze and version rewind
Lint, Release, brew, native upload, and VS Code publish now refuse a rewound CLI version, a non-pymodel VS Code publisher, and a changelog that does not match package.json.
1 parent d52c843 commit d8492e4

15 files changed

Lines changed: 492 additions & 4 deletions

‎.agents/skills/release/SKILL.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ otherwise errors.
7272
Dokploy deploy specifics: see memory `cdn-dokploy-deploy-pipeline`.
7373
- **`pnpm install` fails in CI or locally.** `engine-strict=true` + Node `>=24.15.0` — check
7474
`.nvmrc` before debugging anything else.
75+
- **Identity freeze / version rewind.** Copying another product's `CHANGELOG.md`, `package.json`
76+
`version`, or VS Code `publisher` onto `main` makes Release red while required CI stays green:
77+
npm `latest` does not move backwards, brew 404s the tarball, native `gh release view` misses the
78+
tag, vsce says the extension name already exists, and `verify-release-consistency.mjs` compares
79+
local version to npm `latest`. `scripts/check-identity-freeze.mjs` (lint) plus Release `--npm`
80+
fail closed. Do not merge `ci: release packages` while freeze fields disagree with npm or the
81+
Marketplace. Restore identity from the last published tag; do not hand-bump a lower version.
7582
- **Pre-push hook** (`scripts/pre-push.sh` via simple-git-hooks) gates local pushes; a hook failure
7683
is a real gate failure — fix the cause, never `--no-verify`.
7784

‎.github/workflows/ci.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ jobs:
121121

122122
steps:
123123
- uses: actions/checkout@v4
124+
with:
125+
fetch-depth: 0
124126

125127
- uses: pnpm/action-setup@v6
126128

@@ -131,6 +133,8 @@ jobs:
131133

132134
- run: pnpm install --frozen-lockfile
133135
- run: pnpm run lint
136+
env:
137+
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
134138
- run: pnpm run sherif
135139
- run: pnpm run test:release
136140

‎.github/workflows/release.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ jobs:
110110
- name: Upgrade npm for Trusted Publishing
111111
run: npm install -g npm@11
112112

113+
- name: Identity freeze
114+
run: node scripts/check-identity-freeze.mjs --npm
115+
113116
# `github.event.before` is the exact start of this push. HEAD^ only sees
114117
# the final commit and misses version bumps in a multi-commit push.
115118
- name: Detect release lane version bumps
@@ -621,7 +624,11 @@ jobs:
621624
RELEASE_TAG: ${{ needs.release.outputs.pythinker_release_tag }}
622625
run: |
623626
set -euo pipefail
624-
existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')"
627+
if ! existing="$(gh release view "$RELEASE_TAG" --json assets --jq '.assets[].name')"; then
628+
echo "::error::GitHub release ${RELEASE_TAG} does not exist. npm never published this version, or changesets did not create the tag."
629+
echo "::error::Identity freeze: do not upload native zips onto a missing or orphan version."
630+
exit 1
631+
fi
625632
present=()
626633
absent=()
627634
for path in dist-native-release/*; do

‎.github/workflows/vscode-release.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,16 @@ jobs:
4545
run: |
4646
set -euo pipefail
4747
actual="$(node -p 'require("./apps/vscode/package.json").version')"
48+
publisher="$(node -p 'require("./apps/vscode/package.json").publisher')"
49+
name="$(node -p 'require("./apps/vscode/package.json").name')"
4850
if [ "$actual" != "$EXPECTED_VERSION" ]; then
4951
echo "::error::Requested VS Code version ${EXPECTED_VERSION}, but this ref contains ${actual}."
5052
exit 1
5153
fi
54+
if [ "$publisher" != "pymodel" ] || [ "$name" != "pythinker" ]; then
55+
echo "::error::VS Code identity must be pymodel.pythinker, got ${publisher}.${name}."
56+
exit 1
57+
fi
5258
if [ -z "$VSCE_PAT" ] || [ -z "$OVSX_PAT" ]; then
5359
echo "::error::VSCE_PAT and OVSX_PAT are required. Configure both secrets or set RELEASE_LANE_VSCODE=disabled."
5460
exit 1

‎AGENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ The web bundle: `apps/pythinker-code/dist-web` is the committed, prebuilt bundle
9494
- Prefer adding tests to existing files. Fix failing tests first (unless there's a real impl bug); when a test fails because of a user modification, default to fixing the test first, not the implementation.
9595
- Do not sacrifice code quality for external compatibility unless the user explicitly asks for it.
9696
- Breaking changes require changesets with `major` bump (user confirmation required).
97+
- Identity freeze: never rewind published `package.json` versions, never change the VS Code publisher or extension id (`pymodel.pythinker`), never replace `CHANGELOG.md` with another history. `scripts/check-identity-freeze.mjs` (via `pnpm lint`) and Release `--npm` enforce this.
9798

9899
## Experimental Features
99100

‎apps/vscode/scripts/vsix-verify.mjs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,16 @@ async function verifyTargetManifest(extractionRoot, target) {
141141
}
142142

143143
function verifyPackageManifest(packaged, source) {
144+
if (source.publisher !== 'pymodel' || packaged.publisher !== 'pymodel') {
145+
throw new Error(
146+
`Extension publisher must be pymodel, got source=${String(source.publisher)} packaged=${String(packaged.publisher)}.`,
147+
);
148+
}
149+
if (source.name !== 'pythinker' || packaged.name !== 'pythinker') {
150+
throw new Error(
151+
`Extension name must be pythinker, got source=${String(source.name)} packaged=${String(packaged.name)}.`,
152+
);
153+
}
144154
if (typeof packaged.main !== 'string' || !packaged.main.endsWith('.js')) {
145155
throw new Error(`Packaged extension main must be a .js entry, got ${String(packaged.main)}.`);
146156
}

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"build:plugin-marketplace": "pnpm -C apps/pythinker-code run build:plugin-marketplace",
2222
"dev:docs": "pnpm -C docs install --ignore-workspace && pnpm -C docs run dev",
2323
"typecheck": "pnpm run build:packages && pnpm -r --filter './packages/*' run typecheck && pnpm --filter @pymodel/pythinker-code run typecheck && pnpm --filter @pymodel/pythinker-web run typecheck && pnpm --filter @pymodel/pythinker-desktop run typecheck && pnpm --filter @pymodel/vis-server run typecheck && pnpm --filter @pymodel/vis-web run typecheck",
24-
"lint": "node scripts/check-product-boundaries.mjs && node scripts/check-no-comments.mjs && oxlint --type-aware",
24+
"lint": "node scripts/check-product-boundaries.mjs && node scripts/check-identity-freeze.mjs && node scripts/check-no-comments.mjs && oxlint --type-aware",
2525
"lint:fix": "pnpm run lint --fix",
2626
"lint:pkg": "pnpm --filter @pymodel/pythinker-code exec publint && npm_config_cache=${TMPDIR:-/tmp}/pythinker-code-npm-cache pnpm --filter @pymodel/pythinker-code exec attw --pack . --profile node16",
2727
"sherif": "sherif --ignore-package ./apps/vscode -i @agentclientprotocol/sdk",
@@ -31,7 +31,7 @@
3131
"clean": "pnpm -r run clean",
3232
"changeset": "changeset",
3333
"release:status": "node scripts/release/release-status.mjs",
34-
"test:release": "node --test scripts/release/*.test.mjs",
34+
"test:release": "node --test scripts/release/*.test.mjs scripts/check-identity-freeze.test.mjs",
3535
"version": "changeset version",
3636
"version:release": "node scripts/release/link-desktop-changesets.mjs && changeset version",
3737
"publish": "pnpm run typecheck && pnpm run lint && pnpm run sherif && pnpm run test && pnpm run build && pnpm run lint:pkg && changeset publish",

‎scripts/check-identity-freeze.mjs‎

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { readFileSync } from 'node:fs';
3+
import path from 'node:path';
4+
5+
const ROOT = path.resolve(import.meta.dirname, '..');
6+
const SELF = 'scripts/check-identity-freeze.mjs';
7+
const SELF_TEST = 'scripts/check-identity-freeze.test.mjs';
8+
9+
export const VSCODE_PUBLISHER = 'pymodel';
10+
export const VSCODE_NAME = 'pythinker';
11+
export const VSCODE_DISPLAY_NAME = 'Pythinker';
12+
export const CLI_PACKAGE = '@pymodel/pythinker-code';
13+
export const CAPABILITY_MARKER = 'pymodel.kosong.UNKNOWN_CAPABILITY';
14+
export const FORBIDDEN_CAPABILITY_MARKER = 'moonshot-ai.kosong.UNKNOWN_CAPABILITY';
15+
16+
export const CAPABILITY_FILES = [
17+
'packages/kosong/src/capability.ts',
18+
'packages/agent-core-v2/src/kosong/contract/capability.ts',
19+
'packages/agent-core-v2/src/llm-adapter/contract/capability.ts',
20+
'packages/agent-core-v2/src/human/llm/capability.ts',
21+
];
22+
23+
export const CHANGELOG_FILES = {
24+
cli: 'apps/pythinker-code/CHANGELOG.md',
25+
vscode: 'apps/vscode/CHANGELOG.md',
26+
desktop: 'apps/desktop/CHANGELOG.md',
27+
};
28+
29+
export const MOONSHOT_AI_ALLOW_FILES = new Set([
30+
SELF,
31+
SELF_TEST,
32+
'packages/oauth/src/open-platform.ts',
33+
'packages/oauth/src/refreshProviderModels.ts',
34+
'packages/oauth/test/open-platform.test.ts',
35+
]);
36+
37+
const CORE = /^(\d+)\.(\d+)\.(\d+)/u;
38+
const HEADING = /^## (\d+\.\d+\.\d+)\b/mu;
39+
const PROVIDER_HOST = /api\.moonshot\.(?:ai|cn)/u;
40+
41+
export function parseSemverCore(version) {
42+
if (typeof version !== 'string') return null;
43+
const match = CORE.exec(version);
44+
if (match === null) return null;
45+
return [Number(match[1]), Number(match[2]), Number(match[3])];
46+
}
47+
48+
export function compareSemverCore(left, right) {
49+
const a = parseSemverCore(left);
50+
const b = parseSemverCore(right);
51+
if (a === null || b === null) return null;
52+
for (let index = 0; index < 3; index += 1) {
53+
if (a[index] !== b[index]) return a[index] - b[index];
54+
}
55+
return 0;
56+
}
57+
58+
export function firstChangelogHeading(source) {
59+
if (typeof source !== 'string') return null;
60+
return HEADING.exec(source)?.[1] ?? null;
61+
}
62+
63+
function asText(value) {
64+
return typeof value === 'string' ? value : '-';
65+
}
66+
67+
export function evaluate(input) {
68+
const failures = [];
69+
const vscode = input.vscode ?? {};
70+
if (vscode.publisher !== VSCODE_PUBLISHER) {
71+
failures.push(`VS Code publisher must be ${VSCODE_PUBLISHER}, got ${asText(vscode.publisher)}`);
72+
}
73+
if (vscode.name !== VSCODE_NAME) {
74+
failures.push(`VS Code name must be ${VSCODE_NAME}, got ${asText(vscode.name)}`);
75+
}
76+
if (vscode.displayName !== VSCODE_DISPLAY_NAME) {
77+
failures.push(`VS Code displayName must be ${VSCODE_DISPLAY_NAME}, got ${asText(vscode.displayName)}`);
78+
}
79+
80+
for (const [lane, heading] of Object.entries(input.changelogHeadings ?? {})) {
81+
const version = input.versions?.[lane];
82+
if (typeof version !== 'string') {
83+
failures.push(`${lane} package.json version is missing`);
84+
continue;
85+
}
86+
if (heading !== version) {
87+
failures.push(`${lane} changelog heading ${asText(heading)} does not match version ${version}`);
88+
}
89+
}
90+
91+
for (const [file, source] of Object.entries(input.capabilitySources ?? {})) {
92+
if (!source.includes(CAPABILITY_MARKER)) {
93+
failures.push(`${file}: missing ${CAPABILITY_MARKER}`);
94+
}
95+
if (source.includes(FORBIDDEN_CAPABILITY_MARKER)) {
96+
failures.push(`${file}: still uses ${FORBIDDEN_CAPABILITY_MARKER}`);
97+
}
98+
}
99+
100+
for (const hit of input.moonshotHits ?? []) {
101+
failures.push(`${hit.file}:${hit.line}: moonshot-ai identity token`);
102+
}
103+
104+
for (const [lane, base] of Object.entries(input.baseVersions ?? {})) {
105+
const head = input.versions?.[lane];
106+
const order = compareSemverCore(asText(head), asText(base));
107+
if (order !== null && order < 0) {
108+
failures.push(`${lane} version rewound ${asText(base)} -> ${asText(head)}`);
109+
}
110+
}
111+
112+
if (typeof input.npmLatest === 'string') {
113+
const cliVersion = asText(input.versions?.cli);
114+
const order = compareSemverCore(cliVersion, input.npmLatest);
115+
if (order !== null && order < 0) {
116+
failures.push(`CLI version ${cliVersion} is below npm latest ${input.npmLatest}`);
117+
}
118+
}
119+
120+
return { ok: failures.length === 0, failures };
121+
}
122+
123+
export function moonshotHitAllowed(file, line) {
124+
if (MOONSHOT_AI_ALLOW_FILES.has(file)) return true;
125+
return PROVIDER_HOST.test(line);
126+
}
127+
128+
function readJson(relative) {
129+
return JSON.parse(readFileSync(path.join(ROOT, relative), 'utf8'));
130+
}
131+
132+
function readText(relative) {
133+
return readFileSync(path.join(ROOT, relative), 'utf8');
134+
}
135+
136+
function trackedFiles() {
137+
return execFileSync('git', ['ls-files', '-z'], { cwd: ROOT })
138+
.toString('utf8')
139+
.split('\0')
140+
.filter(Boolean);
141+
}
142+
143+
function scanMoonshotHits() {
144+
const hits = [];
145+
for (const file of trackedFiles()) {
146+
if (file === 'pnpm-lock.yaml' || file.includes('/dist/') || file.includes('/dist-web/')) continue;
147+
let bytes;
148+
try {
149+
bytes = readFileSync(path.join(ROOT, file));
150+
} catch {
151+
continue;
152+
}
153+
if (bytes.length > 2 * 1024 * 1024 || bytes.includes(0)) continue;
154+
let text;
155+
try {
156+
text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
157+
} catch {
158+
continue;
159+
}
160+
for (const [index, line] of text.split(/\r?\n/).entries()) {
161+
if (!line.includes('moonshot-ai')) continue;
162+
if (moonshotHitAllowed(file, line)) continue;
163+
hits.push({ file, line: index + 1, text: line.trim() });
164+
}
165+
}
166+
return hits;
167+
}
168+
169+
function readBaseVersion(baseSha, relative) {
170+
try {
171+
const source = execFileSync('git', ['show', `${baseSha}:${relative}`], {
172+
cwd: ROOT,
173+
encoding: 'utf8',
174+
stdio: ['ignore', 'pipe', 'pipe'],
175+
});
176+
const version = JSON.parse(source).version;
177+
return typeof version === 'string' ? version : undefined;
178+
} catch {
179+
return undefined;
180+
}
181+
}
182+
183+
function readNpmLatest() {
184+
const cleanEnv = { ...process.env };
185+
delete cleanEnv.NODE_AUTH_TOKEN;
186+
delete cleanEnv.NPM_CONFIG_USERCONFIG;
187+
delete cleanEnv.npm_config_userconfig;
188+
return execFileSync(
189+
'npm',
190+
['view', CLI_PACKAGE, 'version', '--registry=https://registry.npmjs.org'],
191+
{ cwd: ROOT, env: cleanEnv, encoding: 'utf8', timeout: 30_000 },
192+
).trim();
193+
}
194+
195+
function main() {
196+
const vscode = readJson('apps/vscode/package.json');
197+
const cli = readJson('apps/pythinker-code/package.json');
198+
const desktop = readJson('apps/desktop/package.json');
199+
const versions = { cli: cli.version, vscode: vscode.version, desktop: desktop.version };
200+
const changelogHeadings = Object.fromEntries(
201+
Object.entries(CHANGELOG_FILES).map(([lane, file]) => [lane, firstChangelogHeading(readText(file))]),
202+
);
203+
const capabilitySources = Object.fromEntries(
204+
CAPABILITY_FILES.map((file) => [file, readText(file)]),
205+
);
206+
207+
const baseSha = process.env.BASE_SHA;
208+
const baseVersions = {};
209+
if (typeof baseSha === 'string' && /^[0-9a-f]{7,40}$/u.test(baseSha)) {
210+
const cliBase = readBaseVersion(baseSha, 'apps/pythinker-code/package.json');
211+
const vscodeBase = readBaseVersion(baseSha, 'apps/vscode/package.json');
212+
const desktopBase = readBaseVersion(baseSha, 'apps/desktop/package.json');
213+
if (cliBase !== undefined) baseVersions.cli = cliBase;
214+
if (vscodeBase !== undefined) baseVersions.vscode = vscodeBase;
215+
if (desktopBase !== undefined) baseVersions.desktop = desktopBase;
216+
}
217+
218+
let npmLatest;
219+
if (process.argv.includes('--npm')) {
220+
try {
221+
npmLatest = readNpmLatest();
222+
} catch (error) {
223+
process.stderr.write(
224+
`Identity freeze failed: cannot read npm latest for ${CLI_PACKAGE}: ${error instanceof Error ? error.message : String(error)}\n`,
225+
);
226+
process.exit(1);
227+
}
228+
}
229+
230+
const result = evaluate({
231+
vscode: {
232+
publisher: vscode.publisher,
233+
name: vscode.name,
234+
displayName: vscode.displayName,
235+
},
236+
versions,
237+
changelogHeadings,
238+
capabilitySources,
239+
moonshotHits: scanMoonshotHits(),
240+
baseVersions: Object.keys(baseVersions).length > 0 ? baseVersions : undefined,
241+
npmLatest,
242+
});
243+
244+
if (!result.ok) {
245+
process.stderr.write(`Identity freeze failed:\n${result.failures.map((line) => `- ${line}`).join('\n')}\n`);
246+
process.exit(1);
247+
}
248+
process.stdout.write('Identity freeze passed.\n');
249+
}
250+
251+
if (process.argv[1] === import.meta.filename) {
252+
try {
253+
main();
254+
} catch (error) {
255+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
256+
process.exitCode = 1;
257+
}
258+
}

0 commit comments

Comments
 (0)