Skip to content

Commit 414dfa0

Browse files
committed
fix: tighten identity freeze against prerelease rewinds
Compare full SemVer, fail closed when npm latest is missing, keep only provider-host moonshot-ai lines, and stop persisting CI checkout credentials.
1 parent d8492e4 commit 414dfa0

6 files changed

Lines changed: 121 additions & 33 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ otherwise errors.
7373
- **`pnpm install` fails in CI or locally.** `engine-strict=true` + Node `>=24.15.0` — check
7474
`.nvmrc` before debugging anything else.
7575
- **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.
76+
`version`, or VS Code `publisher` onto `main` fails required CI (`lint` runs
77+
`scripts/check-identity-freeze.mjs`) and Release (`--npm`). npm `latest` does not move backwards,
78+
brew 404s the tarball, native `gh release view` misses the tag, vsce says the extension name
79+
already exists, and `verify-release-consistency.mjs` compares local version to npm `latest`.
80+
Do not merge `ci: release packages` while freeze fields disagree with npm or the Marketplace.
81+
Restore identity from the last published tag; do not hand-bump a lower version.
8282
- **Pre-push hook** (`scripts/pre-push.sh` via simple-git-hooks) gates local pushes; a hook failure
8383
is a real gate failure — fix the cause, never `--no-verify`.
8484

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ jobs:
123123
- uses: actions/checkout@v4
124124
with:
125125
fetch-depth: 0
126+
persist-credentials: false
126127

127128
- uses: pnpm/action-setup@v6
128129

‎scripts/check-identity-freeze.mjs‎

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,27 +34,54 @@ export const MOONSHOT_AI_ALLOW_FILES = new Set([
3434
'packages/oauth/test/open-platform.test.ts',
3535
]);
3636

37-
const CORE = /^(\d+)\.(\d+)\.(\d+)/u;
38-
const HEADING = /^## (\d+\.\d+\.\d+)\b/mu;
39-
const PROVIDER_HOST = /api\.moonshot\.(?:ai|cn)/u;
37+
const SEMVER =
38+
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u;
39+
const HEADING = /^## (\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\b/mu;
40+
const PROVIDER_HOST = /api\.moonshot\.(?:ai|cn)/gu;
4041

41-
export function parseSemverCore(version) {
42+
export function parseSemver(version) {
4243
if (typeof version !== 'string') return null;
43-
const match = CORE.exec(version);
44+
const match = SEMVER.exec(version);
4445
if (match === null) return null;
45-
return [Number(match[1]), Number(match[2]), Number(match[3])];
46+
return {
47+
core: [Number(match[1]), Number(match[2]), Number(match[3])],
48+
pre: match[4] === undefined ? null : match[4].split('.'),
49+
};
4650
}
4751

48-
export function compareSemverCore(left, right) {
49-
const a = parseSemverCore(left);
50-
const b = parseSemverCore(right);
52+
export function compareSemver(left, right) {
53+
const a = parseSemver(left);
54+
const b = parseSemver(right);
5155
if (a === null || b === null) return null;
5256
for (let index = 0; index < 3; index += 1) {
53-
if (a[index] !== b[index]) return a[index] - b[index];
57+
if (a.core[index] !== b.core[index]) return a.core[index] - b.core[index];
58+
}
59+
if (a.pre === null && b.pre === null) return 0;
60+
if (a.pre === null) return 1;
61+
if (b.pre === null) return -1;
62+
const length = Math.max(a.pre.length, b.pre.length);
63+
for (let index = 0; index < length; index += 1) {
64+
const leftId = a.pre[index];
65+
const rightId = b.pre[index];
66+
if (leftId === undefined) return -1;
67+
if (rightId === undefined) return 1;
68+
const leftNumeric = /^\d+$/u.test(leftId);
69+
const rightNumeric = /^\d+$/u.test(rightId);
70+
if (leftNumeric && rightNumeric) {
71+
const delta = Number(leftId) - Number(rightId);
72+
if (delta !== 0) return delta;
73+
continue;
74+
}
75+
if (leftNumeric) return -1;
76+
if (rightNumeric) return 1;
77+
if (leftId < rightId) return -1;
78+
if (leftId > rightId) return 1;
5479
}
5580
return 0;
5681
}
5782

83+
export const compareSemverCore = compareSemver;
84+
5885
export function firstChangelogHeading(source) {
5986
if (typeof source !== 'string') return null;
6087
return HEADING.exec(source)?.[1] ?? null;
@@ -103,15 +130,15 @@ export function evaluate(input) {
103130

104131
for (const [lane, base] of Object.entries(input.baseVersions ?? {})) {
105132
const head = input.versions?.[lane];
106-
const order = compareSemverCore(asText(head), asText(base));
133+
const order = compareSemver(asText(head), asText(base));
107134
if (order !== null && order < 0) {
108135
failures.push(`${lane} version rewound ${asText(base)} -> ${asText(head)}`);
109136
}
110137
}
111138

112139
if (typeof input.npmLatest === 'string') {
113140
const cliVersion = asText(input.versions?.cli);
114-
const order = compareSemverCore(cliVersion, input.npmLatest);
141+
const order = compareSemver(cliVersion, input.npmLatest);
115142
if (order !== null && order < 0) {
116143
failures.push(`CLI version ${cliVersion} is below npm latest ${input.npmLatest}`);
117144
}
@@ -122,7 +149,7 @@ export function evaluate(input) {
122149

123150
export function moonshotHitAllowed(file, line) {
124151
if (MOONSHOT_AI_ALLOW_FILES.has(file)) return true;
125-
return PROVIDER_HOST.test(line);
152+
return !line.replaceAll(PROVIDER_HOST, '').includes('moonshot-ai');
126153
}
127154

128155
function readJson(relative) {

‎scripts/check-identity-freeze.test.mjs‎

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import {
77
VSCODE_DISPLAY_NAME,
88
VSCODE_NAME,
99
VSCODE_PUBLISHER,
10-
compareSemverCore,
10+
compareSemver,
1111
evaluate,
1212
firstChangelogHeading,
1313
moonshotHitAllowed,
14-
parseSemverCore,
14+
parseSemver,
1515
} from './check-identity-freeze.mjs';
1616

1717
const good = {
@@ -28,21 +28,25 @@ const good = {
2828
moonshotHits: [],
2929
};
3030

31-
void test('parseSemverCore reads the numeric triple and ignores a prerelease', () => {
32-
assert.deepEqual(parseSemverCore('2.1.0'), [2, 1, 0]);
33-
assert.deepEqual(parseSemverCore('0.43.0-beta.1'), [0, 43, 0]);
34-
assert.equal(parseSemverCore('not-a-version'), null);
31+
void test('parseSemver keeps prerelease identifiers', () => {
32+
assert.deepEqual(parseSemver('2.1.0'), { core: [2, 1, 0], pre: null });
33+
assert.deepEqual(parseSemver('2.1.0-beta.1'), { core: [2, 1, 0], pre: ['beta', '1'] });
34+
assert.equal(parseSemver('not-a-version'), null);
3535
});
3636

37-
void test('compareSemverCore orders published lines', () => {
38-
assert.ok(compareSemverCore('2.1.0', '0.43.0') > 0);
39-
assert.ok(compareSemverCore('0.43.0', '2.1.0') < 0);
40-
assert.equal(compareSemverCore('2.1.0', '2.1.0'), 0);
41-
assert.equal(compareSemverCore('bad', '2.1.0'), null);
37+
void test('compareSemver orders stable and prerelease lines', () => {
38+
assert.ok(compareSemver('2.1.0', '0.43.0') > 0);
39+
assert.ok(compareSemver('0.43.0', '2.1.0') < 0);
40+
assert.equal(compareSemver('2.1.0', '2.1.0'), 0);
41+
assert.ok(compareSemver('2.1.0', '2.1.0-beta.1') > 0);
42+
assert.ok(compareSemver('2.1.0-beta.2', '2.1.0-beta.1') > 0);
43+
assert.ok(compareSemver('2.1.0-beta.1', '2.1.0') < 0);
44+
assert.equal(compareSemver('bad', '2.1.0'), null);
4245
});
4346

44-
void test('firstChangelogHeading reads the leading version section', () => {
47+
void test('firstChangelogHeading reads stable and prerelease headings', () => {
4548
assert.equal(firstChangelogHeading('# pkg\n\n## 2.1.0\n\n### Minor\n'), '2.1.0');
49+
assert.equal(firstChangelogHeading('# pkg\n\n## 2.1.0-beta.1\n\n### Minor\n'), '2.1.0-beta.1');
4650
assert.equal(firstChangelogHeading('# pkg\n\nno heading\n'), null);
4751
});
4852

@@ -86,6 +90,33 @@ void test('evaluate rejects a version below npm latest or below the base branch'
8690
});
8791
assert.equal(base.ok, false);
8892
assert.ok(base.failures.some((line) => line.includes('rewound 0.9.7 -> 0.7.6')));
93+
94+
const stableToPre = evaluate({
95+
...good,
96+
versions: { ...good.versions, cli: '2.1.0-beta.1' },
97+
changelogHeadings: { ...good.changelogHeadings, cli: '2.1.0-beta.1' },
98+
baseVersions: { cli: '2.1.0' },
99+
});
100+
assert.equal(stableToPre.ok, false);
101+
assert.ok(stableToPre.failures.some((line) => line.includes('rewound 2.1.0 -> 2.1.0-beta.1')));
102+
103+
const preToPre = evaluate({
104+
...good,
105+
versions: { ...good.versions, cli: '2.1.0-beta.1' },
106+
changelogHeadings: { ...good.changelogHeadings, cli: '2.1.0-beta.1' },
107+
baseVersions: { cli: '2.1.0-beta.2' },
108+
});
109+
assert.equal(preToPre.ok, false);
110+
assert.ok(preToPre.failures.some((line) => line.includes('rewound 2.1.0-beta.2 -> 2.1.0-beta.1')));
111+
});
112+
113+
void test('evaluate accepts a matching prerelease changelog heading', () => {
114+
const result = evaluate({
115+
...good,
116+
versions: { ...good.versions, cli: '2.1.0-beta.1' },
117+
changelogHeadings: { ...good.changelogHeadings, cli: '2.1.0-beta.1' },
118+
});
119+
assert.equal(result.ok, true);
89120
});
90121

91122
void test('moonshotHitAllowed keeps provider hosts and oauth platform files', () => {
@@ -95,4 +126,8 @@ void test('moonshotHitAllowed keeps provider hosts and oauth platform files', ()
95126
moonshotHitAllowed('packages/oauth/src/region.ts', 'https://api.moonshot.ai/v1'),
96127
true,
97128
);
129+
assert.equal(
130+
moonshotHitAllowed('apps/vscode/package.json', "name: 'moonshot-ai' https://api.moonshot.ai/v1"),
131+
false,
132+
);
98133
});

‎scripts/release/changeset-publish-idempotent.mjs‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,18 @@ function npmLatest(name) {
8989
for (const pkg of unpublished) {
9090
if (pkg.name !== '@pymodel/pythinker-code') continue;
9191
const latest = npmLatest(pkg.name);
92-
if (latest === undefined) continue;
92+
if (latest === undefined) {
93+
console.error(`Identity freeze: cannot read npm latest for ${pkg.name}; refusing to publish.`);
94+
process.exit(1);
95+
}
9396
const order = compareSemverCore(pkg.version, latest);
94-
if (order !== null && order < 0) {
97+
if (order === null) {
98+
console.error(
99+
`Identity freeze: cannot compare ${pkg.name}@${pkg.version} to npm latest ${latest}; refusing to publish.`,
100+
);
101+
process.exit(1);
102+
}
103+
if (order < 0) {
95104
console.error(
96105
`Identity freeze: refusing to publish ${pkg.name}@${pkg.version} below npm latest ${latest}.`,
97106
);

‎scripts/release/detect-lane-bumps.test.mjs‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,22 @@ void test('fails closed when a lane version rewinds', async (t) => {
8585
);
8686
});
8787

88+
void test('fails closed when a lane rewinds a prerelease', async (t) => {
89+
const root = await mkdtemp(join(tmpdir(), 'release-lanes-'));
90+
t.after(() => rm(root, { recursive: true, force: true }));
91+
git(root, ['init', '-b', 'main']);
92+
93+
await Promise.all(Object.values(packagePaths).map((path) => writePackage(root, path, '2.1.0-beta.2')));
94+
const before = commit(root, 'beta');
95+
await writePackage(root, packagePaths.cli, '2.1.0-beta.1');
96+
const after = commit(root, 'rewound beta');
97+
98+
assert.throws(
99+
() => detectLaneBumps({ before, after, cwd: root }),
100+
/rewound 2\.1\.0-beta\.2 -> 2\.1\.0-beta\.1/,
101+
);
102+
});
103+
88104
void test('rejects a version that could inject a GitHub output line', async (t) => {
89105
const root = await mkdtemp(join(tmpdir(), 'release-lanes-'));
90106
t.after(() => rm(root, { recursive: true, force: true }));

0 commit comments

Comments
 (0)