release-skill 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codebuddy-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +2 -2
- package/.kimi-plugin/plugin.json +1 -1
- package/CHANGELOG.md +18 -0
- package/INSTALL.md +2 -2
- package/INSTALL.zh-CN.md +2 -2
- package/README.md +10 -18
- package/README.zh-CN.md +10 -18
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/error-codes.json +1 -1
- package/adapters/claude/bin/kernel-protocol.json +1 -1
- package/adapters/claude/bin/registry.json +15 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +35052 -22850
- package/adapters/claude/bin/rules.json +1 -1
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/error-codes.json +1 -1
- package/adapters/codex/bin/kernel-protocol.json +1 -1
- package/adapters/codex/bin/registry.json +15 -1
- package/adapters/codex/bin/release-skill.bundle.mjs +35052 -22850
- package/adapters/codex/bin/rules.json +1 -1
- package/adapters/kimi/.kimi-plugin/plugin.json +1 -1
- package/adapters/kimi/bin/error-codes.json +1 -1
- package/adapters/kimi/bin/kernel-protocol.json +1 -1
- package/adapters/kimi/bin/registry.json +15 -1
- package/adapters/kimi/bin/release-skill.bundle.mjs +35052 -22850
- package/adapters/kimi/bin/rules.json +1 -1
- package/adapters/workbuddy/.codebuddy-plugin/plugin.json +1 -1
- package/adapters/workbuddy/bin/error-codes.json +1 -1
- package/adapters/workbuddy/bin/kernel-protocol.json +1 -1
- package/adapters/workbuddy/bin/registry.json +15 -1
- package/adapters/workbuddy/bin/release-skill.bundle.mjs +35052 -22850
- package/adapters/workbuddy/bin/rules.json +1 -1
- package/bin/error-codes.json +1 -1
- package/bin/kernel-protocol.json +1 -1
- package/bin/registry.json +15 -1
- package/bin/release-skill.bundle.mjs +35052 -22850
- package/bin/rules.json +1 -1
- package/package.json +3 -3
- package/src/adapters/plugin-marketplace.mjs +48 -4
- package/src/adapters/push-snapshot.mjs +12 -4
- package/src/commands/assess.mjs +171 -0
- package/src/commands/hooks.mjs +6 -1
- package/src/commands/prepare.mjs +83 -1
- package/src/core/baseline.mjs +11 -1
- package/src/core/foundation-inflight.mjs +7 -0
- package/src/core/plan.mjs +12 -2
package/bin/rules.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "release-skill",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Safe preparation and frozen GitHub/npm production publishing with full happy end verification",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "广州市风荷科技有限公司"
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
"ajv-formats": "3.0.1",
|
|
77
77
|
"libnpmpublish": "11.1.0",
|
|
78
78
|
"npm-registry-fetch": "18.0.2",
|
|
79
|
-
"skill-family-contracts": "0.
|
|
80
|
-
"skill-family-harness-node": "0.
|
|
79
|
+
"skill-family-contracts": "0.5.0",
|
|
80
|
+
"skill-family-harness-node": "0.5.0",
|
|
81
81
|
"yaml": "2.9.0"
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
@@ -2179,10 +2179,34 @@ export function createPluginMarketplaceAdapter(deps = {}) {
|
|
|
2179
2179
|
// pluginRootForEntrySkill 在非 external 结构化 CLI 路径中已确定;
|
|
2180
2180
|
// external 路径或 human-attestation(null)时回退到 snapshotDirReal。
|
|
2181
2181
|
const pluginRootForSkill = pluginRootForEntrySkill || snapshotDirReal;
|
|
2182
|
-
|
|
2182
|
+
// Prefixed skill-directory fallback (0.5.1 entry-skill-prefix fix,
|
|
2183
|
+
// D-ACA-30 platform_physical_name): some candidate builds name
|
|
2184
|
+
// skill directories `skills/<plugin>-<entrySkill>/` instead of
|
|
2185
|
+
// the unprefixed `skills/<entrySkill>/`. Resolve the unprefixed
|
|
2186
|
+
// layout first; when it misses, retry with the plugin prefix.
|
|
2187
|
+
let entrySkillFile = resolve(pluginRootForSkill, 'skills', action.entrySkill, 'SKILL.md');
|
|
2188
|
+
let entrySkillExists = false;
|
|
2183
2189
|
try {
|
|
2184
2190
|
await stat(entrySkillFile);
|
|
2191
|
+
entrySkillExists = true;
|
|
2185
2192
|
} catch {
|
|
2193
|
+
if (action.plugin) {
|
|
2194
|
+
const prefixed = resolve(
|
|
2195
|
+
pluginRootForSkill,
|
|
2196
|
+
'skills',
|
|
2197
|
+
`${action.plugin}-${action.entrySkill}`,
|
|
2198
|
+
'SKILL.md',
|
|
2199
|
+
);
|
|
2200
|
+
try {
|
|
2201
|
+
await stat(prefixed);
|
|
2202
|
+
entrySkillFile = prefixed;
|
|
2203
|
+
entrySkillExists = true;
|
|
2204
|
+
} catch {
|
|
2205
|
+
// both layouts miss; report below
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
if (!entrySkillExists) {
|
|
2186
2210
|
const skillRel = relative(snapshotDirReal, entrySkillFile) || `skills/${action.entrySkill}/SKILL.md`;
|
|
2187
2211
|
return createResult({
|
|
2188
2212
|
actionType,
|
|
@@ -3476,8 +3500,11 @@ export function createPluginMarketplaceAdapter(deps = {}) {
|
|
|
3476
3500
|
});
|
|
3477
3501
|
}
|
|
3478
3502
|
|
|
3479
|
-
// Verify entry skill exists as a regular file in install dir
|
|
3480
|
-
|
|
3503
|
+
// Verify entry skill exists as a regular file in install dir.
|
|
3504
|
+
// Prefixed skill-directory fallback (0.5.1 entry-skill-prefix fix):
|
|
3505
|
+
// mirror the preflight resolution — `skills/<entrySkill>` first,
|
|
3506
|
+
// then `skills/<plugin>-<entrySkill>` when the plugin is known.
|
|
3507
|
+
let entrySkillPath = resolve(installPath, 'skills', action.entrySkill, 'SKILL.md');
|
|
3481
3508
|
let entrySkillFound = false;
|
|
3482
3509
|
try {
|
|
3483
3510
|
const skillStat = await lstat(entrySkillPath);
|
|
@@ -3485,7 +3512,24 @@ export function createPluginMarketplaceAdapter(deps = {}) {
|
|
|
3485
3512
|
entrySkillFound = true;
|
|
3486
3513
|
}
|
|
3487
3514
|
} catch {
|
|
3488
|
-
// entry skill not found
|
|
3515
|
+
// entry skill not found in unprefixed layout
|
|
3516
|
+
}
|
|
3517
|
+
if (!entrySkillFound && action.plugin) {
|
|
3518
|
+
const prefixedPath = resolve(
|
|
3519
|
+
installPath,
|
|
3520
|
+
'skills',
|
|
3521
|
+
`${action.plugin}-${action.entrySkill}`,
|
|
3522
|
+
'SKILL.md',
|
|
3523
|
+
);
|
|
3524
|
+
try {
|
|
3525
|
+
const skillStat = await lstat(prefixedPath);
|
|
3526
|
+
if (skillStat.isFile() && !skillStat.isSymbolicLink()) {
|
|
3527
|
+
entrySkillPath = prefixedPath;
|
|
3528
|
+
entrySkillFound = true;
|
|
3529
|
+
}
|
|
3530
|
+
} catch {
|
|
3531
|
+
// both layouts miss; report below
|
|
3532
|
+
}
|
|
3489
3533
|
}
|
|
3490
3534
|
|
|
3491
3535
|
if (!entrySkillFound) {
|
|
@@ -58,10 +58,16 @@ function validateAction(action) {
|
|
|
58
58
|
githubRepositoryUrl(action.repo, action.githubHost);
|
|
59
59
|
|
|
60
60
|
const validStrategies = ['create-release-branch', 'advance-existing-branch', 'initialize-default-branch'];
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Explicit strategy is required (0.5.1 chain hardening): the historical
|
|
62
|
+
// `?? 'create-release-branch'` default silently produced orphan root
|
|
63
|
+
// commits whenever a plan omitted the strategy — the root cause of the
|
|
64
|
+
// synthetic-date root commits in the public repositories. The plan layer
|
|
65
|
+
// now guarantees the value; a missing value here fails closed instead of
|
|
66
|
+
// degrading to the orphan-root channel.
|
|
67
|
+
if (typeof action.branchStrategy !== 'string' || !validStrategies.includes(action.branchStrategy)) {
|
|
63
68
|
throw new Error(`branchStrategy must be one of: ${validStrategies.join(', ')}`);
|
|
64
69
|
}
|
|
70
|
+
const strategy = action.branchStrategy;
|
|
65
71
|
if (strategy === 'advance-existing-branch' || strategy === 'initialize-default-branch') {
|
|
66
72
|
if (!action.parentCommit || typeof action.parentCommit !== 'string') {
|
|
67
73
|
throw new Error(`${strategy} requires parentCommit`);
|
|
@@ -126,7 +132,8 @@ export function createPushSnapshotAdapter(deps = {}) {
|
|
|
126
132
|
{ shell: false },
|
|
127
133
|
);
|
|
128
134
|
const remoteTip = stdout.trim().split(/\s+/)[0] ?? '';
|
|
129
|
-
|
|
135
|
+
// Guaranteed by validateAction above (branchStrategy is required).
|
|
136
|
+
const strategy = action.branchStrategy;
|
|
130
137
|
|
|
131
138
|
if (strategy === 'advance-existing-branch') {
|
|
132
139
|
if (!remoteTip) {
|
|
@@ -173,7 +180,8 @@ export function createPushSnapshotAdapter(deps = {}) {
|
|
|
173
180
|
});
|
|
174
181
|
const gitDir = await inspectLocalObjects(action, context, exec);
|
|
175
182
|
const remoteUrl = githubRepositoryUrl(action.repo, action.githubHost);
|
|
176
|
-
|
|
183
|
+
// Guaranteed by validateAction above (branchStrategy is required).
|
|
184
|
+
const strategy = action.branchStrategy;
|
|
177
185
|
|
|
178
186
|
if (strategy === 'advance-existing-branch') {
|
|
179
187
|
await exec(
|
package/src/commands/assess.mjs
CHANGED
|
@@ -709,6 +709,171 @@ async function checkRemotePrerequisites(root, config, offline) {
|
|
|
709
709
|
return gaps;
|
|
710
710
|
}
|
|
711
711
|
|
|
712
|
+
/**
|
|
713
|
+
* Parse a semver-ish version string into comparable components.
|
|
714
|
+
*
|
|
715
|
+
* Accepts `major.minor.patch` with an optional `-prerelease` suffix (which
|
|
716
|
+
* sorts strictly below the same release triple). Returns null for anything
|
|
717
|
+
* that is not a version-shaped tag (candidate names like `0.1.27-candidate.2`
|
|
718
|
+
* parse fine; non-version tags are skipped).
|
|
719
|
+
*
|
|
720
|
+
* @param {string} version
|
|
721
|
+
* @returns {{ major: number, minor: number, patch: number, prerelease: string|null } | null}
|
|
722
|
+
*/
|
|
723
|
+
export function parseSemverVersion(version) {
|
|
724
|
+
const match = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version);
|
|
725
|
+
if (!match) return null;
|
|
726
|
+
return {
|
|
727
|
+
major: Number(match[1]),
|
|
728
|
+
minor: Number(match[2]),
|
|
729
|
+
patch: Number(match[3]),
|
|
730
|
+
prerelease: match[4] ?? null,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Compare two parsed semver values. Returns a negative/zero/positive number.
|
|
736
|
+
*
|
|
737
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} a
|
|
738
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} b
|
|
739
|
+
* @returns {number}
|
|
740
|
+
*/
|
|
741
|
+
export function compareSemverVersions(a, b) {
|
|
742
|
+
if (a.major !== b.major) return a.major - b.major;
|
|
743
|
+
if (a.minor !== b.minor) return a.minor - b.minor;
|
|
744
|
+
if (a.patch !== b.patch) return a.patch - b.patch;
|
|
745
|
+
if (a.prerelease && !b.prerelease) return -1;
|
|
746
|
+
if (!a.prerelease && b.prerelease) return 1;
|
|
747
|
+
return 0;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Describe a version-sequence gap between an immediate predecessor and the
|
|
752
|
+
* target version, or null when the step is contiguous.
|
|
753
|
+
*
|
|
754
|
+
* A gap means some version between the two was never released (e.g. the
|
|
755
|
+
* historical 0.1.1 -> 0.1.3 jump with no v0.1.2 tag). Pre-release steps
|
|
756
|
+
* never count as gaps by themselves.
|
|
757
|
+
*
|
|
758
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} prev
|
|
759
|
+
* @param {{ major: number, minor: number, patch: number, prerelease: string|null }} target
|
|
760
|
+
* @returns {string|null}
|
|
761
|
+
*/
|
|
762
|
+
export function describeVersionSequenceGap(prev, target) {
|
|
763
|
+
if (prev.major !== target.major) {
|
|
764
|
+
return prev.major + 1 < target.major
|
|
765
|
+
? `major ${prev.major}.x.x -> ${target.major}.x.x`
|
|
766
|
+
: null;
|
|
767
|
+
}
|
|
768
|
+
if (prev.minor !== target.minor) {
|
|
769
|
+
return prev.minor + 1 < target.minor
|
|
770
|
+
? `minor ${prev.major}.${prev.minor}.x -> ${target.major}.${target.minor}.x`
|
|
771
|
+
: null;
|
|
772
|
+
}
|
|
773
|
+
if (prev.patch !== target.patch) {
|
|
774
|
+
return prev.patch + 1 < target.patch
|
|
775
|
+
? `patch ${prev.major}.${prev.minor}.${prev.patch} -> ${target.major}.${target.minor}.${target.patch}`
|
|
776
|
+
: null;
|
|
777
|
+
}
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Build a regex that extracts the version from a tag name following the
|
|
783
|
+
* unit's tagTemplate (`{version}` placeholder, regex-special characters
|
|
784
|
+
* escaped).
|
|
785
|
+
*
|
|
786
|
+
* @param {string} tagTemplate
|
|
787
|
+
* @returns {RegExp}
|
|
788
|
+
*/
|
|
789
|
+
export function buildTagVersionRegex(tagTemplate) {
|
|
790
|
+
const escaped = tagTemplate
|
|
791
|
+
.split('{version}')
|
|
792
|
+
.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
793
|
+
.join('(.+)');
|
|
794
|
+
return new RegExp(`^${escaped}$`);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* Check the public repository's release tag sequence for version jumps
|
|
799
|
+
* (0.5.1 chain hardening): when the immediate predecessor release is more
|
|
800
|
+
* than one version step below the current target version, register a
|
|
801
|
+
* warning-level gap `VERSION_SEQUENCE_GAP` (e.g. 0.1.1 -> 0.1.3 with no
|
|
802
|
+
* v0.1.2 tag). This never blocks: it records the observation for the human
|
|
803
|
+
* release planner.
|
|
804
|
+
*
|
|
805
|
+
* @param {string} root - Project root.
|
|
806
|
+
* @param {Object} config - The loaded project config.
|
|
807
|
+
* @returns {Promise<Object[]>} Array of warning gap entries.
|
|
808
|
+
*/
|
|
809
|
+
async function checkReleaseTagSequence(root, config) {
|
|
810
|
+
const gaps = [];
|
|
811
|
+
for (const unit of config.releaseUnits ?? []) {
|
|
812
|
+
const tagTemplate = unit.version?.tagTemplate;
|
|
813
|
+
if (!tagTemplate || !unit.publicRepo) continue;
|
|
814
|
+
|
|
815
|
+
const versionRegex = buildTagVersionRegex(tagTemplate);
|
|
816
|
+
const tagPattern = tagTemplate.replace('{version}', '*');
|
|
817
|
+
|
|
818
|
+
let stdout;
|
|
819
|
+
try {
|
|
820
|
+
({ stdout } = await execFile(
|
|
821
|
+
'git',
|
|
822
|
+
['ls-remote', `https://github.com/${unit.publicRepo}.git`, `refs/tags/${tagPattern}`],
|
|
823
|
+
{ cwd: root, shell: false, encoding: 'utf8', timeout: 15_000 },
|
|
824
|
+
));
|
|
825
|
+
} catch {
|
|
826
|
+
// Tag enumeration failure (network/auth/repo absent) is not a version
|
|
827
|
+
// gap and must not block; the remote-prerequisites npm check already
|
|
828
|
+
// reports unreachable registries where relevant.
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
let targetVersion;
|
|
833
|
+
try {
|
|
834
|
+
const pkg = JSON.parse(await readFile(resolve(root, unit.source, 'package.json'), 'utf8'));
|
|
835
|
+
targetVersion = pkg.version;
|
|
836
|
+
} catch {
|
|
837
|
+
continue; // package metadata issues are reported elsewhere
|
|
838
|
+
}
|
|
839
|
+
const targetParsed = parseSemverVersion(targetVersion);
|
|
840
|
+
if (!targetParsed) continue;
|
|
841
|
+
|
|
842
|
+
let previousParsed = null;
|
|
843
|
+
let previousTag = null;
|
|
844
|
+
for (const line of stdout.trim().split('\n')) {
|
|
845
|
+
if (!line) continue;
|
|
846
|
+
const tagName = line.split('\t')[1]?.replace(/^refs\/tags\//, '');
|
|
847
|
+
if (!tagName) continue;
|
|
848
|
+
const versionMatch = versionRegex.exec(tagName);
|
|
849
|
+
if (!versionMatch) continue;
|
|
850
|
+
const parsed = parseSemverVersion(versionMatch[1]);
|
|
851
|
+
if (!parsed) continue;
|
|
852
|
+
// Prereleases never qualify as the "previous release" predecessor.
|
|
853
|
+
if (parsed.prerelease) continue;
|
|
854
|
+
if (compareSemverVersions(parsed, targetParsed) >= 0) continue;
|
|
855
|
+
if (previousParsed === null || compareSemverVersions(parsed, previousParsed) > 0) {
|
|
856
|
+
previousParsed = parsed;
|
|
857
|
+
previousTag = tagName;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (!previousParsed) continue;
|
|
862
|
+
const gap = describeVersionSequenceGap(previousParsed, targetParsed);
|
|
863
|
+
if (gap) {
|
|
864
|
+
gaps.push(createGap({
|
|
865
|
+
scope: GapScope.PROJECT,
|
|
866
|
+
category: GapCategory.REMOTE,
|
|
867
|
+
severity: Severity.WARNING,
|
|
868
|
+
code: 'VERSION_SEQUENCE_GAP',
|
|
869
|
+
message: `发布单元 "${unit.id}" 的版本序列存在跳号(${gap},上一发布标签 ${previousTag},目标版本 ${targetVersion}):请确认中间版本未发布是否是有意为之;发布链仍会以链式历史推进,但跳过的版本不会补造标签`,
|
|
870
|
+
file: '.release-skill/project.yaml',
|
|
871
|
+
}));
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return gaps;
|
|
875
|
+
}
|
|
876
|
+
|
|
712
877
|
/**
|
|
713
878
|
* Perform a basic README structural check.
|
|
714
879
|
*
|
|
@@ -967,6 +1132,12 @@ export async function assessProject(options) {
|
|
|
967
1132
|
const remoteGaps = await checkRemotePrerequisites(root, config, offline);
|
|
968
1133
|
allGaps.push(...remoteGaps);
|
|
969
1134
|
|
|
1135
|
+
// --- 7b. Release tag sequence (online only; warning, never blocking) ---
|
|
1136
|
+
if (!offline) {
|
|
1137
|
+
const tagSequenceGaps = await checkReleaseTagSequence(root, config);
|
|
1138
|
+
allGaps.push(...tagSequenceGaps);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
970
1141
|
// --- 8. README structure check ---
|
|
971
1142
|
const readmeGaps = await checkReadmeStructure(root, config);
|
|
972
1143
|
allGaps.push(...readmeGaps);
|
package/src/commands/hooks.mjs
CHANGED
|
@@ -28,7 +28,12 @@ export async function validateDeclaredHooks(options = {}) {
|
|
|
28
28
|
clock: () => new Date().toISOString(),
|
|
29
29
|
});
|
|
30
30
|
const startedAt = new Date().toISOString();
|
|
31
|
-
await runDeclaredHooks(config, root, evidence, undefined, {
|
|
31
|
+
await runDeclaredHooks(config, root, evidence, undefined, {
|
|
32
|
+
hookCache,
|
|
33
|
+
// Explicit env delivery (0.5.1 hook-env-delivery fix): same semantics as
|
|
34
|
+
// prepare — allowlisted keys are read from this explicit map only.
|
|
35
|
+
env: process.env,
|
|
36
|
+
});
|
|
32
37
|
return {
|
|
33
38
|
command: 'hooks validate',
|
|
34
39
|
status: 'PASSED',
|
package/src/commands/prepare.mjs
CHANGED
|
@@ -243,6 +243,13 @@ export async function resolveAllUnitVersions(units, root, explicitVersion, evide
|
|
|
243
243
|
* @param {object} [options]
|
|
244
244
|
* @param {boolean} [options.hookCache=true] - When false (--no-hook-cache),
|
|
245
245
|
* every hook runs in full and the cache is neither read nor written.
|
|
246
|
+
* @param {Record<string, string>} [options.env] - Explicit environment map
|
|
247
|
+
* merged into the hook context (`hookFn(hook, { root, env })`). The hook
|
|
248
|
+
* runner's `buildFilteredEnv` reads `envAllowlist` keys exclusively from
|
|
249
|
+
* this map (never from process.env), so the caller decides what is
|
|
250
|
+
* injectable. Defaults to process.env at the prepare call site, which makes
|
|
251
|
+
* allowlisted keys exported by the invoking shell reach the hook
|
|
252
|
+
* subprocess.
|
|
246
253
|
* @returns {Promise<void>}
|
|
247
254
|
* @throws {ReleaseError} GATE_FAILED if any hook returns a non-zero exit code,
|
|
248
255
|
* throws, or declares a cacheInputs glob that matches no file.
|
|
@@ -294,7 +301,10 @@ export async function runDeclaredHooks(config, root, evidence, hookFn = runHook,
|
|
|
294
301
|
|
|
295
302
|
let result;
|
|
296
303
|
try {
|
|
297
|
-
result = await hookFn(hook, {
|
|
304
|
+
result = await hookFn(hook, {
|
|
305
|
+
root,
|
|
306
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
307
|
+
});
|
|
298
308
|
} catch (err) {
|
|
299
309
|
await evidence.append({
|
|
300
310
|
phase: 'hooks',
|
|
@@ -1850,6 +1860,11 @@ export async function prepareRelease(options) {
|
|
|
1850
1860
|
await evidence.append({ phase: 'hooks', status: 'started' });
|
|
1851
1861
|
await runDeclaredHooks(config, realRoot, evidence, options.runHookFn ?? runHook, {
|
|
1852
1862
|
hookCache: options.hookCache,
|
|
1863
|
+
// Explicit env delivery (0.5.1 hook-env-delivery fix): the hook runner
|
|
1864
|
+
// reads envAllowlist keys exclusively from context.env, so the invoking
|
|
1865
|
+
// shell's environment is injected here explicitly. Allowlist semantics
|
|
1866
|
+
// are unchanged — only allowlisted keys from this map reach the child.
|
|
1867
|
+
env: options.env ?? process.env,
|
|
1853
1868
|
});
|
|
1854
1869
|
await evidence.append({ phase: 'hooks', status: 'completed' });
|
|
1855
1870
|
|
|
@@ -2077,6 +2092,29 @@ export async function prepareRelease(options) {
|
|
|
2077
2092
|
}
|
|
2078
2093
|
};
|
|
2079
2094
|
const observeDefaultBranch = options.observeDefaultBranchFn ?? defaultObserveDefaultBranchFn;
|
|
2095
|
+
// CHAIN_GAP detection (0.5.1 chain hardening): when a unit declares
|
|
2096
|
+
// previousPublicBaseline.mode=none (first-release semantics) but the
|
|
2097
|
+
// public repository already carries release tags for the unit's
|
|
2098
|
+
// tagTemplate, a subsequent release pretending to be the first would
|
|
2099
|
+
// freeze a plan whose push-snapshot creates a new orphan root commit.
|
|
2100
|
+
// Detection is online-only: `git ls-remote` on the tag pattern
|
|
2101
|
+
// (tagTemplate with {version} → *). Any match proves a prior release.
|
|
2102
|
+
const defaultPriorReleaseTagDetector = async (repo, tagPattern, { githubHost = 'github.com' } = {}) => {
|
|
2103
|
+
try {
|
|
2104
|
+
const { stdout } = await execFile(
|
|
2105
|
+
'git',
|
|
2106
|
+
['ls-remote', `https://${githubHost}/${repo}.git`, `refs/tags/${tagPattern}`],
|
|
2107
|
+
{ shell: false, encoding: 'utf8', timeout: 30000 },
|
|
2108
|
+
);
|
|
2109
|
+
return { found: stdout.trim().length > 0 };
|
|
2110
|
+
} catch (err) {
|
|
2111
|
+
// Network/auth failure: unknown, not "no prior release". The caller
|
|
2112
|
+
// records a warning and continues (consistent with the bound-baseline
|
|
2113
|
+
// observer's unknown status); it never silently proves first release.
|
|
2114
|
+
return { error: err.message };
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
const detectPriorReleaseTags = options.detectPriorReleaseTagsFn ?? defaultPriorReleaseTagDetector;
|
|
2080
2118
|
const unitBaselineResults = new Map();
|
|
2081
2119
|
for (let unitIndex = 0; unitIndex < configUnits.length; unitIndex += 1) {
|
|
2082
2120
|
const unit = configUnits[unitIndex];
|
|
@@ -2120,6 +2158,50 @@ export async function prepareRelease(options) {
|
|
|
2120
2158
|
});
|
|
2121
2159
|
|
|
2122
2160
|
if (ppbConfig.mode === "none") {
|
|
2161
|
+
// Chain-integrity gate (0.5.1, CHAIN_GAP): a production online
|
|
2162
|
+
// prepare must not freeze a first-release plan for a repository that
|
|
2163
|
+
// has already published. This is the orphan-root channel that caused
|
|
2164
|
+
// the synthetic 2000-01-01 root commits in the historical public
|
|
2165
|
+
// repositories: first releases used mode=none, push-snapshot degraded
|
|
2166
|
+
// to create-release-branch with no parent, and every later release
|
|
2167
|
+
// started a fresh lineage. Fail closed and demand a bound baseline
|
|
2168
|
+
// pointing at the previous release commit instead.
|
|
2169
|
+
if (production && !offline) {
|
|
2170
|
+
const tagTemplate = unit.version?.tagTemplate;
|
|
2171
|
+
const tagPattern = tagTemplate ? tagTemplate.replace('{version}', '*') : null;
|
|
2172
|
+
if (tagPattern) {
|
|
2173
|
+
const detection = await detectPriorReleaseTags(unit.publicRepo, tagPattern, {
|
|
2174
|
+
githubHost: productionGithubHost,
|
|
2175
|
+
});
|
|
2176
|
+
if (detection.found) {
|
|
2177
|
+
await evidence.append({
|
|
2178
|
+
phase: "previous-public-baseline",
|
|
2179
|
+
unitId: unit.id,
|
|
2180
|
+
status: "blocking",
|
|
2181
|
+
reason: "CHAIN_GAP",
|
|
2182
|
+
repo: unit.publicRepo,
|
|
2183
|
+
tagPattern,
|
|
2184
|
+
guidance: "非首次发布:必须把 previousPublicBaseline 绑定到上一发布提交(mode=bound),不能以 mode=none 制造新的孤儿根提交",
|
|
2185
|
+
});
|
|
2186
|
+
throw new ReleaseError(
|
|
2187
|
+
GATE_FAILED,
|
|
2188
|
+
`unit "${unit.id}" previousPublicBaseline.mode=none but the public repository already has release tags matching "${tagPattern}" (CHAIN_GAP): bind the previous public release commit as a bound baseline`,
|
|
2189
|
+
{ unitId: unit.id, reason: 'CHAIN_GAP', repo: unit.publicRepo, tagPattern },
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
if (detection.error) {
|
|
2193
|
+
await evidence.append({
|
|
2194
|
+
phase: "previous-public-baseline",
|
|
2195
|
+
unitId: unit.id,
|
|
2196
|
+
status: "warning",
|
|
2197
|
+
reason: "CHAIN_GAP_DETECTION_FAILED",
|
|
2198
|
+
repo: unit.publicRepo,
|
|
2199
|
+
tagPattern,
|
|
2200
|
+
error: detection.error,
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2123
2205
|
unitBaselineResults.set(unit.id, {
|
|
2124
2206
|
mode: "none",
|
|
2125
2207
|
status: "consistent",
|
package/src/core/baseline.mjs
CHANGED
|
@@ -146,7 +146,17 @@ function parseStatusPorcelainZ(statusOut) {
|
|
|
146
146
|
* @returns {Promise<string>} Hex-encoded SHA-256 digest.
|
|
147
147
|
*/
|
|
148
148
|
async function computeWorkspaceDigest(root) {
|
|
149
|
-
|
|
149
|
+
// 64 MiB upper bound (official fix 39c631f): `git ls-files -s -z` emits one
|
|
150
|
+
// line per tracked file and routinely exceeds Node's default 1 MiB
|
|
151
|
+
// execFile maxBuffer on large repositories (measured 8494 tracked files →
|
|
152
|
+
// 1,519,151 B stdout). Large enough for any realistic baseline, small
|
|
153
|
+
// enough to fail closed before exhausting memory.
|
|
154
|
+
const opts = {
|
|
155
|
+
cwd: root,
|
|
156
|
+
shell: false,
|
|
157
|
+
encoding: 'utf8',
|
|
158
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
159
|
+
};
|
|
150
160
|
|
|
151
161
|
const [
|
|
152
162
|
{ stdout: stagedOut },
|
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
* 本桥从 5 级相对路径 import(包外工作树引用)切换为包名 import
|
|
14
14
|
* ('skill-family-harness-node'),随 npm 依赖发布,不再依赖 Foundation 工作树在旁。
|
|
15
15
|
*
|
|
16
|
+
* 0.5.1 依赖提升:skill-family-contracts / skill-family-harness-node 由 0.4.0
|
|
17
|
+
* 提升至 0.5.0(npm latest,2026-08-16 发布)。harness-node 0.5.0 导出面已逐项
|
|
18
|
+
* 核对:publishFileExclusive(atomic.mjs)、acquireFilesystemLock /
|
|
19
|
+
* inspectFilesystemLock / releaseFilesystemLock / recoverFilesystemLock
|
|
20
|
+
* (token-lock.mjs)、HARNESS_ERROR_KINDS(errors.mjs)均在 index.mjs 导出面,
|
|
21
|
+
* 包名 import 无需改动。
|
|
22
|
+
*
|
|
16
23
|
* 发布形态:包名 import 可直接用于包内 src(node_modules 解析),bundle
|
|
17
24
|
* (bin/release-skill.bundle.mjs,esbuild 内联)保持自包含。
|
|
18
25
|
*
|
package/src/core/plan.mjs
CHANGED
|
@@ -534,8 +534,18 @@ export function validatePlanActionCompleteness(plan, options = {}) {
|
|
|
534
534
|
if (!frozen.parentCommit || frozen.parentCommit !== unit.previousPublicBaseline?.commit) {
|
|
535
535
|
failures.push(`unit "${unitId}" frozenSnapshot.parentCommit does not match previous public baseline commit`);
|
|
536
536
|
}
|
|
537
|
-
} else if (
|
|
538
|
-
|
|
537
|
+
} else if (branchStrategy === 'create-release-branch') {
|
|
538
|
+
// Chain-integrity gate (0.5.1): create-release-branch creates an
|
|
539
|
+
// orphan root commit (no parent). It is only legitimate for the
|
|
540
|
+
// FIRST release of a repository (previousPublicBaseline.mode=none).
|
|
541
|
+
// A later release using it would start a fresh lineage disconnected
|
|
542
|
+
// from the published history — the historical orphan-root channel.
|
|
543
|
+
if (unit.previousPublicBaseline?.mode === 'bound') {
|
|
544
|
+
failures.push(`unit "${unitId}" create-release-branch is only valid for first releases (previousPublicBaseline.mode=none), but a bound previous public baseline exists`);
|
|
545
|
+
}
|
|
546
|
+
if (frozen.parentCommit) {
|
|
547
|
+
failures.push(`unit "${unitId}" create-release-branch must not freeze a parentCommit`);
|
|
548
|
+
}
|
|
539
549
|
}
|
|
540
550
|
if (
|
|
541
551
|
branchStrategy === 'advance-existing-branch' &&
|