Skip to content

Commit b494f12

Browse files
authored
Merge branch 'main' into fix/qwen-hook-fallback-fail-closed
2 parents 8b09a93 + 4f5d434 commit b494f12

6 files changed

Lines changed: 129 additions & 31 deletions

File tree

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ jobs:
139139
fi
140140
141141
TAG="v${PKG_VERSION}"
142-
TAG_SHA=$(git rev-parse "$TAG^{commit}" 2>/dev/null || true)
142+
TAG_SHA=$(git rev-parse --verify --quiet "$TAG^{commit}" 2>/dev/null || true)
143143
if [ -n "$TAG_SHA" ]; then
144144
if [ "$TAG_SHA" != "$TARGET_SHA" ]; then
145145
echo "::error::Tag $TAG exists at $TAG_SHA, not target commit $TARGET_SHA"
@@ -191,8 +191,8 @@ jobs:
191191
exit 1
192192
fi
193193
194-
if git rev-parse "$VERSION" >/dev/null 2>&1; then
195-
EXISTING_SHA=$(git rev-parse "$VERSION^{commit}")
194+
EXISTING_SHA=$(git rev-parse --verify --quiet "$VERSION^{commit}" 2>/dev/null || true)
195+
if [ -n "$EXISTING_SHA" ]; then
196196
if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then
197197
echo "Tag $VERSION already exists at $EXISTING_SHA. Skipping."
198198
exit 0

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515

1616
### Changed
1717

18-
- **npm package identity**: renamed the planned npm package to `@josstei/maestro`, added `hello@josstei.dev` to public author metadata, and moved the stable release publish path toward GitHub Actions trusted publishing.
18+
- **npm package identity**: renamed the planned npm package to `@josstei/maestro`, added `hello@josstei.dev` to public author metadata, and moved the stable release publish path into GitHub Actions with npm token authentication.
1919

2020
### Fixed
2121

22+
- **Stable npm release recovery**: Release now uses `NPM_TOKEN` for stable publishes, uses verified Git tag lookups, supports manual recovery from an existing `vX.Y.Z` tag and target SHA, and enforces a stable-only `latest` dist-tag through the idempotent npm publish helper. Prerelease publishes defer stale `latest` repair when no stable version exists instead of attempting to delete npm's `latest` tag.
2223
- **Codex plugin MCP server fails to start**: corrected `npx` args in `plugins/maestro/.mcp.json` — added `-p`/`--package` flag so `maestro-mcp-server` is resolved as the binary name rather than an argument to the package's default binary.
2324
- **Release metadata drift**: runtime manifests, marketplace entries, detached payload versions, and Codex MCP package specs are now generated from `package.json` so stable and prerelease packages stay self-consistent.
2425

docs/cicd.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -655,4 +655,4 @@ The `RELEASE_TOKEN` used by Prepare Release is a personal access token with elev
655655
| `preview` | PR preview build | `X.Y.Z-preview.SHORT_SHA` | Preview Build |
656656
| `nightly` | Daily main snapshot | `X.Y.Z-nightly.YYYYMMDD` | Nightly Build |
657657

658-
`latest` must never point at `rc`, `preview`, or `nightly`. If a prerelease publish or idempotent skip sees `latest` pointing to a prerelease, the helper repairs it by moving `latest` back to the highest published stable version or removing it when no stable exists.
658+
`latest` must never intentionally point at `rc`, `preview`, or `nightly`. If a prerelease publish or idempotent skip sees `latest` pointing to a prerelease and at least one stable version exists, the helper repairs it by moving `latest` back to the highest published stable version. If no stable version exists yet, the helper logs a warning and defers repair until the stable Release workflow publishes `X.Y.Z`; it does not delete `latest`.

scripts/npm-publish-idempotent.js

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,45 +206,79 @@ function validatePublishTag(pkg, tag) {
206206
}
207207
}
208208

209-
function ensureLatestTagPolicy(pkg, runner) {
209+
function ensureLatestTagPolicy(pkg, runner, logger = console) {
210210
const tags = getDistTags(pkg.name, runner);
211-
const latest = tags.latest;
211+
const latest = tags.latest || null;
212212

213213
if (isPrereleaseVersion(pkg.version)) {
214214
if (!latest || !isPrereleaseVersion(latest)) {
215-
return;
215+
return {
216+
latest,
217+
reason: latest
218+
? `latest already points to stable version ${latest}.`
219+
: 'latest dist-tag is not set.',
220+
status: 'ok',
221+
target: null,
222+
};
216223
}
217224

218225
const stableVersion = highestStableVersion(getPublishedVersions(pkg.name, runner));
219226
if (stableVersion) {
220227
runner('npm', ['dist-tag', 'add', `${pkg.name}@${stableVersion}`, 'latest'], {
221228
stdio: 'inherit',
222229
});
223-
} else {
224-
runner('npm', ['dist-tag', 'rm', pkg.name, 'latest'], {
225-
stdio: 'inherit',
226-
});
230+
logger.log(`Moved npm latest dist-tag from ${latest} to stable ${stableVersion}.`);
231+
return {
232+
latest,
233+
reason: `latest pointed to prerelease ${latest}; moved it back to stable ${stableVersion}.`,
234+
status: 'moved',
235+
target: stableVersion,
236+
};
227237
}
228-
return;
238+
239+
const reason = `latest points to prerelease ${latest}, but no stable versions are published; stable release must move latest.`;
240+
logger.warn(`Warning: ${reason}`);
241+
return {
242+
latest,
243+
reason,
244+
status: 'deferred',
245+
target: null,
246+
};
229247
}
230248

231249
if (latest !== pkg.version) {
232250
runner('npm', ['dist-tag', 'add', `${pkg.name}@${pkg.version}`, 'latest'], {
233251
stdio: 'inherit',
234252
});
253+
logger.log(`Moved npm latest dist-tag from ${latest || '<unset>'} to stable ${pkg.version}.`);
254+
return {
255+
latest,
256+
reason: `latest pointed to ${latest || '<unset>'}; moved it to stable ${pkg.version}.`,
257+
status: 'moved',
258+
target: pkg.version,
259+
};
235260
}
261+
262+
return {
263+
latest,
264+
reason: `latest already points to stable version ${pkg.version}.`,
265+
status: 'ok',
266+
target: pkg.version,
267+
};
236268
}
237269

238270
function publishIfNeeded(options = {}) {
239271
const root = options.root || ROOT;
240272
const runner = options.execFileSync || execFileSync;
273+
const logger = options.logger || console;
241274
const pkg = readPackage(root);
242275
const packageSpec = `${pkg.name}@${pkg.version}`;
243276
validatePublishTag(pkg, options.tag);
244277

245278
if (packageVersionExists(packageSpec, runner)) {
246-
ensureLatestTagPolicy(pkg, runner);
279+
const latestPolicy = ensureLatestTagPolicy(pkg, runner, logger);
247280
return {
281+
latestPolicy,
248282
packageSpec,
249283
published: false,
250284
};
@@ -261,9 +295,10 @@ function publishIfNeeded(options = {}) {
261295
stdio: 'inherit',
262296
});
263297

264-
ensureLatestTagPolicy(pkg, runner);
298+
const latestPolicy = ensureLatestTagPolicy(pkg, runner, logger);
265299

266300
return {
301+
latestPolicy,
267302
packageSpec,
268303
published: true,
269304
};

tests/unit/npm-publish-idempotent.test.js

Lines changed: 76 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('idempotent npm publish', () => {
3838
try {
3939
const result = publishIfNeeded({
4040
root,
41+
logger: { log: () => {}, warn: () => {} },
4142
tag: 'rc',
4243
execFileSync: (cmd, args) => {
4344
calls.push([cmd, args]);
@@ -48,10 +49,9 @@ describe('idempotent npm publish', () => {
4849
},
4950
});
5051

51-
assert.deepEqual(result, {
52-
packageSpec: '@josstei/maestro@1.2.3-rc.1',
53-
published: false,
54-
});
52+
assert.equal(result.packageSpec, '@josstei/maestro@1.2.3-rc.1');
53+
assert.equal(result.published, false);
54+
assert.equal(result.latestPolicy.status, 'ok');
5555
assert.deepEqual(calls, [
5656
['npm', ['view', '@josstei/maestro@1.2.3-rc.1', 'version']],
5757
['npm', ['dist-tag', 'ls', '@josstei/maestro']],
@@ -68,6 +68,7 @@ describe('idempotent npm publish', () => {
6868
try {
6969
const result = publishIfNeeded({
7070
root,
71+
logger: { log: () => {}, warn: () => {} },
7172
tag: 'preview',
7273
access: 'public',
7374
execFileSync: (cmd, args) => {
@@ -82,10 +83,9 @@ describe('idempotent npm publish', () => {
8283
},
8384
});
8485

85-
assert.deepEqual(result, {
86-
packageSpec: '@josstei/maestro@1.2.3-preview.abcdef0',
87-
published: true,
88-
});
86+
assert.equal(result.packageSpec, '@josstei/maestro@1.2.3-preview.abcdef0');
87+
assert.equal(result.published, true);
88+
assert.equal(result.latestPolicy.status, 'ok');
8989
assert.deepEqual(calls[1], ['npm', ['publish', '--tag', 'preview', '--access', 'public']]);
9090
} finally {
9191
fs.rmSync(root, { recursive: true, force: true });
@@ -99,6 +99,7 @@ describe('idempotent npm publish', () => {
9999
try {
100100
const result = publishIfNeeded({
101101
root,
102+
logger: { log: () => {}, warn: () => {} },
102103
access: 'public',
103104
execFileSync: (cmd, args) => {
104105
calls.push([cmd, args]);
@@ -112,10 +113,11 @@ describe('idempotent npm publish', () => {
112113
},
113114
});
114115

115-
assert.deepEqual(result, {
116-
packageSpec: '@josstei/maestro@1.2.3',
117-
published: true,
118-
});
116+
assert.equal(result.packageSpec, '@josstei/maestro@1.2.3');
117+
assert.equal(result.published, true);
118+
assert.equal(result.latestPolicy.status, 'moved');
119+
assert.equal(result.latestPolicy.latest, '1.2.2');
120+
assert.equal(result.latestPolicy.target, '1.2.3');
119121
assert.deepEqual(calls[1], ['npm', ['publish', '--access', 'public']]);
120122
assert.deepEqual(calls.at(-1), [
121123
'npm',
@@ -126,6 +128,40 @@ describe('idempotent npm publish', () => {
126128
}
127129
});
128130

131+
it('moves latest from a prerelease to stable when stable already exists', () => {
132+
const root = createPackageRoot('1.2.3');
133+
const calls = [];
134+
135+
try {
136+
const result = publishIfNeeded({
137+
root,
138+
logger: { log: () => {}, warn: () => {} },
139+
execFileSync: (cmd, args) => {
140+
calls.push([cmd, args]);
141+
if (args[0] === 'view') {
142+
return '1.2.3\n';
143+
}
144+
if (args[0] === 'dist-tag' && args[1] === 'ls') {
145+
return 'latest: 1.2.4-rc.1\nrc: 1.2.4-rc.1\n';
146+
}
147+
return '';
148+
},
149+
});
150+
151+
assert.equal(result.packageSpec, '@josstei/maestro@1.2.3');
152+
assert.equal(result.published, false);
153+
assert.equal(result.latestPolicy.status, 'moved');
154+
assert.equal(result.latestPolicy.latest, '1.2.4-rc.1');
155+
assert.equal(result.latestPolicy.target, '1.2.3');
156+
assert.deepEqual(calls.at(-1), [
157+
'npm',
158+
['dist-tag', 'add', '@josstei/maestro@1.2.3', 'latest'],
159+
]);
160+
} finally {
161+
fs.rmSync(root, { recursive: true, force: true });
162+
}
163+
});
164+
129165
it('rejects prerelease versions published without a prerelease tag', () => {
130166
const root = createPackageRoot('1.2.3-rc.1');
131167

@@ -165,13 +201,15 @@ describe('idempotent npm publish', () => {
165201
}
166202
});
167203

168-
it('removes latest when it points to a prerelease and no stable exists', () => {
204+
it('defers latest repair when it points to a prerelease and no stable exists', () => {
169205
const root = createPackageRoot('1.2.3-rc.1');
170206
const calls = [];
207+
const warnings = [];
171208

172209
try {
173-
publishIfNeeded({
210+
const result = publishIfNeeded({
174211
root,
212+
logger: { log: () => {}, warn: (message) => warnings.push(message) },
175213
tag: 'rc',
176214
execFileSync: (cmd, args) => {
177215
calls.push([cmd, args]);
@@ -188,7 +226,15 @@ describe('idempotent npm publish', () => {
188226
},
189227
});
190228

191-
assert.deepEqual(calls.at(-1), ['npm', ['dist-tag', 'rm', '@josstei/maestro', 'latest']]);
229+
assert.equal(result.latestPolicy.status, 'deferred');
230+
assert.equal(result.latestPolicy.latest, '1.2.3-rc.1');
231+
assert.equal(result.latestPolicy.target, null);
232+
assert.match(result.latestPolicy.reason, /no stable versions are published/);
233+
assert.match(warnings[0], /stable release must move latest/);
234+
assert.equal(
235+
calls.some(([, args]) => args[0] === 'dist-tag' && args[1] === 'rm'),
236+
false
237+
);
192238
} finally {
193239
fs.rmSync(root, { recursive: true, force: true });
194240
}
@@ -199,8 +245,9 @@ describe('idempotent npm publish', () => {
199245
const calls = [];
200246

201247
try {
202-
publishIfNeeded({
248+
const result = publishIfNeeded({
203249
root,
250+
logger: { log: () => {}, warn: () => {} },
204251
tag: 'rc',
205252
execFileSync: (cmd, args) => {
206253
calls.push([cmd, args]);
@@ -217,6 +264,9 @@ describe('idempotent npm publish', () => {
217264
},
218265
});
219266

267+
assert.equal(result.latestPolicy.status, 'moved');
268+
assert.equal(result.latestPolicy.latest, '1.3.0-rc.1');
269+
assert.equal(result.latestPolicy.target, '1.2.0');
220270
assert.deepEqual(calls.at(-1), [
221271
'npm',
222272
['dist-tag', 'add', '@josstei/maestro@1.2.0', 'latest'],
@@ -263,4 +313,14 @@ describe('idempotent npm publish', () => {
263313
});
264314
assert.equal(highestStableVersion(['1.2.9', '1.10.0', '2.0.0-rc.1']), '1.10.0');
265315
});
316+
317+
it('does not remove latest through npm dist-tag policy', () => {
318+
const source = fs.readFileSync(
319+
path.resolve(__dirname, '..', '..', 'scripts', 'npm-publish-idempotent.js'),
320+
'utf8'
321+
);
322+
323+
assert.doesNotMatch(source, /dist-tag['"],\s*['"]rm/);
324+
assert.doesNotMatch(source, /dist-tag rm/);
325+
});
266326
});

tests/unit/workflow-security.test.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ describe('workflow shell security', () => {
103103
assert.match(content, /NPM_TOKEN: \$\{\{ secrets\.NPM_TOKEN \}\}/);
104104
assert.match(content, /NODE_AUTH_TOKEN: \$\{\{ env\.NPM_TOKEN \}\}/);
105105
assert.match(content, /NPM_TOKEN is required for stable release publishing/);
106+
assert.match(content, /git rev-parse --verify --quiet "\$TAG\^\{commit\}"/);
107+
assert.match(content, /git rev-parse --verify --quiet "\$VERSION\^\{commit\}"/);
106108
assert.match(content, /Manual release recovery requires existing tag \$TAG/);
107109
assert.match(content, /Tag \$TAG exists at \$TAG_SHA, not target commit \$TARGET_SHA/);
108110
});

0 commit comments

Comments
 (0)