release-skill 0.1.3 → 0.1.5
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 +2 -2
- package/.codex-plugin/plugin.json +3 -3
- package/CHANGELOG.md +56 -0
- package/INSTALL.md +69 -32
- package/INSTALL.zh-CN.md +64 -26
- package/README.md +111 -37
- package/README.zh-CN.md +95 -28
- package/adapters/claude/.claude-plugin/marketplace.json +1 -1
- package/adapters/claude/.claude-plugin/plugin.json +1 -1
- package/adapters/claude/bin/release-skill.bundle.mjs +79371 -0
- package/adapters/claude/bin/release-skill.mjs +34 -0
- package/adapters/claude/native/safe-write/binding.gyp +40 -0
- package/adapters/claude/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/claude/native/safe-write/prebuilds.json +24 -0
- package/adapters/claude/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/claude/schemas/.render-manifest.json +37 -0
- package/adapters/claude/schemas/approval-record.schema.json +115 -0
- package/adapters/claude/schemas/artifact-lock.schema.json +111 -0
- package/adapters/claude/schemas/artifact-plan.schema.json +52 -0
- package/adapters/claude/schemas/artifact-policy.schema.json +76 -0
- package/adapters/claude/schemas/evidence-event.schema.json +89 -0
- package/adapters/claude/schemas/release-plan.schema.json +860 -0
- package/adapters/claude/schemas/release-project.schema.json +759 -0
- package/adapters/claude/schemas/release-run.schema.json +342 -0
- package/adapters/claude/skills/release-assess/SKILL.md +5 -6
- package/adapters/claude/skills/release-help/SKILL.md +11 -18
- package/adapters/claude/skills/release-prepare/SKILL.md +6 -6
- package/adapters/claude/skills/release-publish/SKILL.md +1 -4
- package/adapters/claude/skills/release-reconcile/SKILL.md +5 -5
- package/adapters/claude/skills/release-setup/SKILL.md +64 -80
- package/adapters/claude/skills/release-verify/SKILL.md +4 -7
- package/adapters/codex/.codex-plugin/plugin.json +2 -2
- package/adapters/codex/bin/release-skill.bundle.mjs +79371 -0
- package/adapters/codex/bin/release-skill.mjs +34 -0
- package/adapters/codex/native/safe-write/binding.gyp +40 -0
- package/adapters/codex/native/safe-write/prebuilds/darwin-arm64/safe_write.node +0 -0
- package/adapters/codex/native/safe-write/prebuilds.json +24 -0
- package/adapters/codex/native/safe-write/src/safe_write.cc +2032 -0
- package/adapters/codex/schemas/.render-manifest.json +37 -0
- package/adapters/codex/schemas/approval-record.schema.json +115 -0
- package/adapters/codex/schemas/artifact-lock.schema.json +111 -0
- package/adapters/codex/schemas/artifact-plan.schema.json +52 -0
- package/adapters/codex/schemas/artifact-policy.schema.json +76 -0
- package/adapters/codex/schemas/evidence-event.schema.json +89 -0
- package/adapters/codex/schemas/release-plan.schema.json +860 -0
- package/adapters/codex/schemas/release-project.schema.json +759 -0
- package/adapters/codex/schemas/release-run.schema.json +342 -0
- package/adapters/codex/skills/release-assess/SKILL.md +12 -6
- package/adapters/codex/skills/release-help/SKILL.md +18 -18
- package/adapters/codex/skills/release-prepare/SKILL.md +13 -6
- package/adapters/codex/skills/release-publish/SKILL.md +8 -4
- package/adapters/codex/skills/release-reconcile/SKILL.md +12 -5
- package/adapters/codex/skills/release-setup/SKILL.md +71 -80
- package/adapters/codex/skills/release-verify/SKILL.md +11 -7
- package/bin/release-skill-cli.mjs +807 -0
- package/bin/release-skill.bundle.mjs +79371 -0
- package/bin/release-skill.mjs +23 -788
- package/package.json +6 -2
- package/references/02-project-config.md +2 -2
- package/schemas/release-plan.schema.json +7 -1
- package/schemas/release-project.schema.json +31 -0
- package/scripts/build-bundle.mjs +133 -0
- package/skills/release-assess/SKILL.md +5 -6
- package/skills/release-help/SKILL.md +11 -18
- package/skills/release-prepare/SKILL.md +6 -6
- package/skills/release-publish/SKILL.md +1 -4
- package/skills/release-reconcile/SKILL.md +5 -5
- package/skills/release-setup/SKILL.md +64 -80
- package/skills/release-verify/SKILL.md +4 -7
- package/skills-src/release-assess/SKILL.md +5 -6
- package/skills-src/release-help/SKILL.md +11 -18
- package/skills-src/release-prepare/SKILL.md +6 -6
- package/skills-src/release-publish/SKILL.md +1 -4
- package/skills-src/release-reconcile/SKILL.md +5 -5
- package/skills-src/release-setup/SKILL.md +64 -80
- package/skills-src/release-verify/SKILL.md +4 -7
- package/src/adapters/npm.mjs +5 -13
- package/src/adapters/plugin-marketplace.mjs +137 -34
- package/src/artifacts/policy.mjs +4 -7
- package/src/artifacts/safe-fs-backend-internal.mjs +69 -21
- package/src/commands/prepare.mjs +30 -1
- package/src/commands/publish.mjs +1 -0
- package/src/commands/reconcile.mjs +4 -1
- package/src/commands/setup.mjs +667 -28
- package/src/commands/verify.mjs +4 -1
- package/src/core/approval.mjs +4 -6
- package/src/core/config.mjs +8 -8
- package/src/core/pkg-root.mjs +22 -0
- package/src/core/plan.mjs +68 -5
- package/src/core/run.mjs +4 -4
- package/src/core/trusted-resource.mjs +96 -0
- package/src/docs/version-gate.mjs +164 -0
- package/src/producers/build-adapters.mjs +512 -55
- package/src/snapshot/frozen.mjs +133 -3
- package/src/snapshot/public-map.mjs +7 -4
- package/src/snapshot/scan.mjs +2 -1
package/src/commands/verify.mjs
CHANGED
|
@@ -669,7 +669,10 @@ export async function verifyRelease(options) {
|
|
|
669
669
|
}
|
|
670
670
|
|
|
671
671
|
// Validate plan action completeness before checkpoint mapping
|
|
672
|
-
|
|
672
|
+
// Use legacyCompatibility: old plans (pre-v0.1.5) lack
|
|
673
|
+
// parameters.timeoutMs. Verify must still pass these plans,
|
|
674
|
+
// while strict mode (prepare/approve/publish) rejects them.
|
|
675
|
+
const completenessResult = validatePlanActionCompleteness(plan, { legacyCompatibility: true });
|
|
673
676
|
if (!completenessResult.passed) {
|
|
674
677
|
throw new ReleaseError(
|
|
675
678
|
GATE_FAILED,
|
package/src/core/approval.mjs
CHANGED
|
@@ -19,16 +19,14 @@ import { computePlanDigest } from './plan.mjs';
|
|
|
19
19
|
import { sha256Hex } from './digest.mjs';
|
|
20
20
|
import { readFile } from 'node:fs/promises';
|
|
21
21
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
22
|
-
import { fileURLToPath } from 'node:url';
|
|
23
22
|
import Ajv from 'ajv';
|
|
24
23
|
import addFormats from 'ajv-formats';
|
|
25
24
|
import { WORKSPACE_DIGEST_ALGORITHM } from './baseline.mjs';
|
|
25
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
26
26
|
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
'utf8',
|
|
31
|
-
));
|
|
27
|
+
const approvalSchema = JSON.parse((await readTrustedPackageResource(
|
|
28
|
+
'schemas/approval-record.schema.json',
|
|
29
|
+
)).toString('utf8'));
|
|
32
30
|
const approvalAjv = new Ajv({ allErrors: true, strict: false });
|
|
33
31
|
addFormats(approvalAjv);
|
|
34
32
|
const validateApprovalSchema = approvalAjv.compile(approvalSchema);
|
package/src/core/config.mjs
CHANGED
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { readFile } from 'node:fs/promises';
|
|
20
|
-
import { resolve, isAbsolute, relative, normalize
|
|
21
|
-
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { resolve, isAbsolute, relative, normalize } from 'node:path';
|
|
22
21
|
import YAML, { Alias } from 'yaml';
|
|
23
22
|
import Ajv from 'ajv';
|
|
24
23
|
import addFormats from 'ajv-formats';
|
|
@@ -26,6 +25,7 @@ import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
|
26
25
|
import { ReleaseError, CONFIG_INVALID } from './errors.mjs';
|
|
27
26
|
import { canonicalPublicPath, publicPathCollisionKey } from '../snapshot/public-path.mjs';
|
|
28
27
|
import { isReservedReleaseControlPath } from './baseline.mjs';
|
|
28
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// Constants
|
|
@@ -38,19 +38,19 @@ const DEFAULT_CONFIG_REL = '.release-skill/project.yaml';
|
|
|
38
38
|
// Load the formal release-project JSON Schema (single source of truth)
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
40
40
|
|
|
41
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
42
|
-
const FORMAL_SCHEMA_PATH = resolve(__dirname, '..', '..', 'schemas', 'release-project.schema.json');
|
|
43
|
-
|
|
44
41
|
let RELEASE_PROJECT_SCHEMA;
|
|
45
42
|
try {
|
|
46
|
-
const schemaRaw = await
|
|
43
|
+
const schemaRaw = (await readTrustedPackageResource(
|
|
44
|
+
'schemas/release-project.schema.json',
|
|
45
|
+
)).toString('utf8');
|
|
47
46
|
RELEASE_PROJECT_SCHEMA = JSON.parse(schemaRaw);
|
|
48
47
|
} catch (err) {
|
|
48
|
+
if (err instanceof ReleaseError) throw err;
|
|
49
49
|
// Fail closed: if the formal schema cannot be loaded, refuse to operate.
|
|
50
50
|
throw new ReleaseError(
|
|
51
51
|
CONFIG_INVALID,
|
|
52
|
-
|
|
53
|
-
{
|
|
52
|
+
'cannot parse formal release-project schema',
|
|
53
|
+
{ resource: 'schemas/release-project.schema.json', cause: err.code ?? 'PARSE_FAILED' },
|
|
54
54
|
);
|
|
55
55
|
}
|
|
56
56
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package root resolution for bundled and unbundled execution.
|
|
3
|
+
*
|
|
4
|
+
* In development (unbundled) mode, `import.meta.url` points to the source
|
|
5
|
+
* file under `src/core/`, so `../..` reaches the package root.
|
|
6
|
+
*
|
|
7
|
+
* In bundled mode, the esbuild banner defines `__bundlePkgRoot` before the
|
|
8
|
+
* module body executes, derived deterministically from the bundle's own
|
|
9
|
+
* file path. The env-var override path has been removed to prevent
|
|
10
|
+
* callers from hijacking schema/native resolution.
|
|
11
|
+
*
|
|
12
|
+
* @module core/pkg-root
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { dirname, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
// In bundled mode, __bundlePkgRoot is set by the esbuild banner.
|
|
19
|
+
// In development mode, resolve from this source file's location.
|
|
20
|
+
export const PKG_ROOT = typeof __bundlePkgRoot !== 'undefined'
|
|
21
|
+
? __bundlePkgRoot
|
|
22
|
+
: resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
package/src/core/plan.mjs
CHANGED
|
@@ -17,19 +17,19 @@
|
|
|
17
17
|
|
|
18
18
|
import { readFile, writeFile, rename, mkdir, open, link, unlink, lstat } from 'node:fs/promises';
|
|
19
19
|
import { basename, dirname, join, resolve, parse, sep } from 'node:path';
|
|
20
|
-
import { fileURLToPath } from 'node:url';
|
|
21
20
|
import Ajv from 'ajv';
|
|
22
21
|
import addFormats from 'ajv-formats';
|
|
23
22
|
import { canonicalJson, sha256Hex } from './digest.mjs';
|
|
24
23
|
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
24
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
25
25
|
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
27
|
// Schema loaded from the authoritative JSON file (single source of truth)
|
|
28
28
|
// ---------------------------------------------------------------------------
|
|
29
29
|
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
const RELEASE_PLAN_SCHEMA = JSON.parse((await readTrustedPackageResource(
|
|
31
|
+
'schemas/release-plan.schema.json',
|
|
32
|
+
)).toString('utf8'));
|
|
33
33
|
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
// Schema validator (compiled once at module init)
|
|
@@ -312,9 +312,13 @@ const REQUIRED_ACTION_TYPES = ['push-snapshot', 'create-tag', 'github-release'];
|
|
|
312
312
|
* (e.g. v0.0.10 must not match expected version 0.0.1).
|
|
313
313
|
*
|
|
314
314
|
* @param {object} plan - A validated release plan object.
|
|
315
|
+
* @param {object} [options]
|
|
316
|
+
* @param {boolean} [options.legacyCompatibility] - When true, relax
|
|
317
|
+
* timeoutMs requirement for old plans (reconcile/verify paths).
|
|
318
|
+
* New prepare/approve/publish must not set this flag.
|
|
315
319
|
* @returns {{ passed: boolean, details: { failures: string[], expectedCount: number, actualCount: number } }}
|
|
316
320
|
*/
|
|
317
|
-
export function validatePlanActionCompleteness(plan) {
|
|
321
|
+
export function validatePlanActionCompleteness(plan, options = {}) {
|
|
318
322
|
const failures = [];
|
|
319
323
|
|
|
320
324
|
if (!plan || typeof plan !== 'object') {
|
|
@@ -386,6 +390,11 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
386
390
|
if (frozen.branchStrategy !== branchStrategy) {
|
|
387
391
|
failures.push(`unit "${unitId}" frozenSnapshot.branchStrategy does not match productionConfig.branchStrategy`);
|
|
388
392
|
}
|
|
393
|
+
if (!frozen.commitTimestamp || typeof frozen.commitTimestamp !== 'string') {
|
|
394
|
+
failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp is missing; legacy production plans without a freeze timestamp are rejected, never silently backfilled`);
|
|
395
|
+
} else if (plan.createdAt !== frozen.commitTimestamp) {
|
|
396
|
+
failures.push(`unit "${unitId}" frozenSnapshot.commitTimestamp must equal plan.createdAt`);
|
|
397
|
+
}
|
|
389
398
|
if (['advance-existing-branch', 'initialize-default-branch'].includes(branchStrategy)) {
|
|
390
399
|
if (unit.previousPublicBaseline?.mode !== 'bound') {
|
|
391
400
|
failures.push(`unit "${unitId}" branch strategy "${branchStrategy}" requires a bound previous public baseline`);
|
|
@@ -665,6 +674,33 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
665
674
|
_checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
|
|
666
675
|
_checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
667
676
|
}
|
|
677
|
+
// timeoutMs is mandatory for all marketplace install actions.
|
|
678
|
+
// Legacy plans (pre-v0.1.5) lack this field; legacyCompatibility
|
|
679
|
+
// relaxes the check for reconcile/verify paths only, and only when
|
|
680
|
+
// the property is genuinely absent (undefined). An explicit null is
|
|
681
|
+
// NOT "absent" -- it is an invalid value and always fails closed,
|
|
682
|
+
// like strings or out-of-range numbers, even in legacyCompatibility.
|
|
683
|
+
{
|
|
684
|
+
const raw = action.parameters?.timeoutMs;
|
|
685
|
+
if (raw === undefined) {
|
|
686
|
+
if (!options.legacyCompatibility) {
|
|
687
|
+
failures.push(
|
|
688
|
+
`unit "${unitId}", action "${action.id}": parameters.timeoutMs is missing, expected a valid timeout (30000-900000)`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
} else {
|
|
692
|
+
// Field is present -- always validate range/type, even in legacy mode
|
|
693
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
|
|
694
|
+
failures.push(
|
|
695
|
+
`unit "${unitId}", action "${action.id}": parameters.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
|
|
696
|
+
);
|
|
697
|
+
} else if (raw < 30000 || raw > 900000) {
|
|
698
|
+
failures.push(
|
|
699
|
+
`unit "${unitId}", action "${action.id}": parameters.timeoutMs must be between 30000 and 900000, got: ${raw}`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
668
704
|
|
|
669
705
|
// Expected checks
|
|
670
706
|
_checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
|
|
@@ -741,6 +777,33 @@ export function validatePlanActionCompleteness(plan) {
|
|
|
741
777
|
_checkRequired(action, 'parameters.ref', action.parameters?.ref, expectedTag, unitId, failures);
|
|
742
778
|
_checkRequired(action, 'parameters.manifestDigest', action.parameters?.manifestDigest, frozen?.manifestDigest, unitId, failures);
|
|
743
779
|
}
|
|
780
|
+
// timeoutMs is mandatory for all marketplace install actions.
|
|
781
|
+
// Legacy plans (pre-v0.1.5) lack this field; legacyCompatibility
|
|
782
|
+
// relaxes the check for reconcile/verify paths only, and only when
|
|
783
|
+
// the property is genuinely absent (undefined). An explicit null is
|
|
784
|
+
// NOT "absent" -- it is an invalid value and always fails closed,
|
|
785
|
+
// like strings or out-of-range numbers, even in legacyCompatibility.
|
|
786
|
+
{
|
|
787
|
+
const raw = action.parameters?.timeoutMs;
|
|
788
|
+
if (raw === undefined) {
|
|
789
|
+
if (!options.legacyCompatibility) {
|
|
790
|
+
failures.push(
|
|
791
|
+
`unit "${unitId}", action "${action.id}": parameters.timeoutMs is missing, expected a valid timeout (30000-900000)`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
} else {
|
|
795
|
+
// Field is present -- always validate range/type, even in legacy mode
|
|
796
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) {
|
|
797
|
+
failures.push(
|
|
798
|
+
`unit "${unitId}", action "${action.id}": parameters.timeoutMs must be a finite integer, got: ${JSON.stringify(raw)}`,
|
|
799
|
+
);
|
|
800
|
+
} else if (raw < 30000 || raw > 900000) {
|
|
801
|
+
failures.push(
|
|
802
|
+
`unit "${unitId}", action "${action.id}": parameters.timeoutMs must be between 30000 and 900000, got: ${raw}`,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
744
807
|
|
|
745
808
|
// Expected checks
|
|
746
809
|
_checkRequired(action, 'expected.installed', action.expected?.installed, true, unitId, failures);
|
package/src/core/run.mjs
CHANGED
|
@@ -15,17 +15,17 @@
|
|
|
15
15
|
import { link, lstat, mkdir, open, readFile, unlink } from 'node:fs/promises';
|
|
16
16
|
import { realpathSync } from 'node:fs';
|
|
17
17
|
import { dirname, join, resolve, basename, relative, isAbsolute } from 'node:path';
|
|
18
|
-
import { fileURLToPath } from 'node:url';
|
|
19
18
|
import Ajv from 'ajv';
|
|
20
19
|
import addFormats from 'ajv-formats';
|
|
21
20
|
|
|
22
21
|
import { assertImmutablePlanAuthority, computePlanDigest } from './plan.mjs';
|
|
23
22
|
import { sha256Hex } from './digest.mjs';
|
|
24
23
|
import { ReleaseError, GATE_FAILED } from './errors.mjs';
|
|
24
|
+
import { readTrustedPackageResource } from './trusted-resource.mjs';
|
|
25
25
|
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
const RELEASE_RUN_SCHEMA = JSON.parse((await readTrustedPackageResource(
|
|
27
|
+
'schemas/release-run.schema.json',
|
|
28
|
+
)).toString('utf8'));
|
|
29
29
|
|
|
30
30
|
// ---------------------------------------------------------------------------
|
|
31
31
|
// Default runDir resolution
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read package-owned runtime resources without following untrusted links.
|
|
3
|
+
* Installed adapters use this for schemas and native manifests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
7
|
+
import { lstat, readFile, realpath } from 'node:fs/promises';
|
|
8
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
9
|
+
|
|
10
|
+
import { CONFIG_INVALID, ReleaseError } from './errors.mjs';
|
|
11
|
+
import { PKG_ROOT } from './pkg-root.mjs';
|
|
12
|
+
|
|
13
|
+
function fail(code, resource, reason) {
|
|
14
|
+
throw new ReleaseError(
|
|
15
|
+
code,
|
|
16
|
+
`package resource is untrusted or unavailable: ${resource}`,
|
|
17
|
+
{ resource, reason },
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function lexicalPath(resource, code) {
|
|
22
|
+
if (typeof resource !== 'string' || resource.length === 0 || isAbsolute(resource)) {
|
|
23
|
+
fail(code, String(resource), 'INVALID_RESOURCE_PATH');
|
|
24
|
+
}
|
|
25
|
+
const path = resolve(PKG_ROOT, resource);
|
|
26
|
+
const rel = relative(PKG_ROOT, path);
|
|
27
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
28
|
+
fail(code, resource, 'RESOURCE_PATH_ESCAPE');
|
|
29
|
+
}
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function assertStat(stat, resource, code) {
|
|
34
|
+
if (stat.isSymbolicLink()) fail(code, resource, 'SYMLINK');
|
|
35
|
+
if (!stat.isFile()) fail(code, resource, 'NOT_REGULAR_FILE');
|
|
36
|
+
if (stat.nlink !== 1) fail(code, resource, 'UNEXPECTED_HARDLINK_COUNT');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function assertPhysicalContainment(physicalRoot, physicalPath, resource, code) {
|
|
40
|
+
const rel = relative(physicalRoot, physicalPath);
|
|
41
|
+
if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
42
|
+
fail(code, resource, 'PHYSICAL_PATH_ESCAPE');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readTrustedPackageResourceSync(resource, { code = CONFIG_INVALID } = {}) {
|
|
47
|
+
const path = lexicalPath(resource, code);
|
|
48
|
+
let stat;
|
|
49
|
+
try {
|
|
50
|
+
stat = lstatSync(path);
|
|
51
|
+
} catch {
|
|
52
|
+
fail(code, resource, 'MISSING');
|
|
53
|
+
}
|
|
54
|
+
assertStat(stat, resource, code);
|
|
55
|
+
|
|
56
|
+
let physicalRoot;
|
|
57
|
+
let physicalPath;
|
|
58
|
+
try {
|
|
59
|
+
physicalRoot = realpathSync(PKG_ROOT);
|
|
60
|
+
physicalPath = realpathSync(path);
|
|
61
|
+
} catch {
|
|
62
|
+
fail(code, resource, 'REALPATH_FAILED');
|
|
63
|
+
}
|
|
64
|
+
assertPhysicalContainment(physicalRoot, physicalPath, resource, code);
|
|
65
|
+
try {
|
|
66
|
+
return readFileSync(physicalPath);
|
|
67
|
+
} catch {
|
|
68
|
+
fail(code, resource, 'READ_FAILED');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function readTrustedPackageResource(resource, { code = CONFIG_INVALID } = {}) {
|
|
73
|
+
const path = lexicalPath(resource, code);
|
|
74
|
+
let stat;
|
|
75
|
+
try {
|
|
76
|
+
stat = await lstat(path);
|
|
77
|
+
} catch {
|
|
78
|
+
fail(code, resource, 'MISSING');
|
|
79
|
+
}
|
|
80
|
+
assertStat(stat, resource, code);
|
|
81
|
+
|
|
82
|
+
let physicalRoot;
|
|
83
|
+
let physicalPath;
|
|
84
|
+
try {
|
|
85
|
+
physicalRoot = await realpath(PKG_ROOT);
|
|
86
|
+
physicalPath = await realpath(path);
|
|
87
|
+
} catch {
|
|
88
|
+
fail(code, resource, 'REALPATH_FAILED');
|
|
89
|
+
}
|
|
90
|
+
assertPhysicalContainment(physicalRoot, physicalPath, resource, code);
|
|
91
|
+
try {
|
|
92
|
+
return await readFile(physicalPath);
|
|
93
|
+
} catch {
|
|
94
|
+
fail(code, resource, 'READ_FAILED');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release docs version consistency gate.
|
|
3
|
+
*
|
|
4
|
+
* README, INSTALL, and CHANGELOG are human-maintained content. A release
|
|
5
|
+
* freezes only the current truth: prepare/publish never rewrite or refresh
|
|
6
|
+
* these docs. Maintainers must update the docs first, then prepare, review,
|
|
7
|
+
* and approve. This gate makes version drift fail closed before release:
|
|
8
|
+
*
|
|
9
|
+
* - the English README, Chinese README, English INSTALL, and Chinese
|
|
10
|
+
* INSTALL each carry a machine-readable current release version marker
|
|
11
|
+
* whose value equals package.json.version exactly;
|
|
12
|
+
* - CHANGELOG.md carries a formal `## [<version>]` heading for the current
|
|
13
|
+
* package version (a body mention or an internal candidate note does not
|
|
14
|
+
* substitute for the heading).
|
|
15
|
+
*
|
|
16
|
+
* The gate is a pure read-only check; it never edits files. It is wired into
|
|
17
|
+
* the package test suite so `pnpm test:release` blocks a prepare when docs
|
|
18
|
+
* have drifted from the package version.
|
|
19
|
+
*
|
|
20
|
+
* @module src/docs/version-gate
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
|
|
26
|
+
/** The machine-readable marker tag carried by every public doc file. */
|
|
27
|
+
export const RELEASE_VERSION_MARKER_TAG = 'release-skill:release-version';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Marker pattern. The value is captured; e.g.
|
|
31
|
+
* <!-- release-skill:release-version: 0.1.4 -->
|
|
32
|
+
* Global: every occurrence is validated so a stale duplicate cannot hide.
|
|
33
|
+
*/
|
|
34
|
+
const MARKER_PATTERN = new RegExp(
|
|
35
|
+
`<!--\\s*${RELEASE_VERSION_MARKER_TAG}:\\s*([^\\s>]+)\\s*-->`,
|
|
36
|
+
'g',
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/** Public docs that must carry the marker, in both languages. */
|
|
40
|
+
export const VERSIONED_DOC_FILES = [
|
|
41
|
+
'README.md',
|
|
42
|
+
'README.zh-CN.md',
|
|
43
|
+
'INSTALL.md',
|
|
44
|
+
'INSTALL.zh-CN.md',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Escape a string for literal use inside a RegExp.
|
|
51
|
+
* @param {string} text
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function escapeRegExp(text) {
|
|
55
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract every release-version marker value from doc content.
|
|
60
|
+
* @param {string} content
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
export function extractReleaseVersionMarkers(content) {
|
|
64
|
+
return [...String(content ?? '').matchAll(MARKER_PATTERN)].map((match) => match[1]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render the canonical marker line for a version.
|
|
69
|
+
* @param {string} version
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function renderReleaseVersionMarker(version) {
|
|
73
|
+
return `<!-- ${RELEASE_VERSION_MARKER_TAG}: ${version} -->`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Validate that the human-maintained release docs in `packageDir` agree with
|
|
78
|
+
* package.json.version. Read-only; never writes.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} options
|
|
81
|
+
* @param {string} options.packageDir Directory containing package.json and
|
|
82
|
+
* the public docs.
|
|
83
|
+
* @returns {Promise<{
|
|
84
|
+
* passed: boolean,
|
|
85
|
+
* version: string | null,
|
|
86
|
+
* markers: Record<string, string[]>,
|
|
87
|
+
* changelogHeading: boolean,
|
|
88
|
+
* failures: string[],
|
|
89
|
+
* }>}
|
|
90
|
+
*/
|
|
91
|
+
export async function validateDocsVersionConsistency({ packageDir }) {
|
|
92
|
+
const failures = [];
|
|
93
|
+
const markers = {};
|
|
94
|
+
let changelogHeading = false;
|
|
95
|
+
let version = null;
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const pkg = JSON.parse(await readFile(join(packageDir, 'package.json'), 'utf8'));
|
|
99
|
+
if (typeof pkg.version === 'string' && pkg.version.length > 0) {
|
|
100
|
+
version = pkg.version;
|
|
101
|
+
}
|
|
102
|
+
} catch (error) {
|
|
103
|
+
failures.push(`package.json: cannot read package version (${error?.code ?? error?.message ?? 'unknown error'})`);
|
|
104
|
+
}
|
|
105
|
+
if (version === null) {
|
|
106
|
+
failures.push('package.json: version is missing; docs version gate fails closed');
|
|
107
|
+
} else if (!SEMVER_PATTERN.test(version)) {
|
|
108
|
+
failures.push(`package.json: version "${version}" is not a valid semver value`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (version !== null) {
|
|
112
|
+
for (const file of VERSIONED_DOC_FILES) {
|
|
113
|
+
let content;
|
|
114
|
+
try {
|
|
115
|
+
content = await readFile(join(packageDir, file), 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
failures.push(`${file}: missing; release docs must ship in both languages`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const found = extractReleaseVersionMarkers(content);
|
|
121
|
+
markers[file] = found;
|
|
122
|
+
if (found.length === 0) {
|
|
123
|
+
failures.push(
|
|
124
|
+
`${file}: missing machine-readable release version marker "${renderReleaseVersionMarker(version)}"`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
for (const value of found) {
|
|
128
|
+
if (value !== version) {
|
|
129
|
+
failures.push(
|
|
130
|
+
`${file}: release version marker "${value}" does not match package version "${version}"; ` +
|
|
131
|
+
'update the docs first, then prepare, review, and approve',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let changelog;
|
|
138
|
+
try {
|
|
139
|
+
changelog = await readFile(join(packageDir, 'CHANGELOG.md'), 'utf8');
|
|
140
|
+
} catch {
|
|
141
|
+
failures.push('CHANGELOG.md: missing');
|
|
142
|
+
}
|
|
143
|
+
if (changelog !== undefined) {
|
|
144
|
+
// A formal heading starts a line: "## [0.1.4]" optionally followed by a
|
|
145
|
+
// date. A body mention ("0.1.4 was an internal candidate...") does not
|
|
146
|
+
// substitute for the heading.
|
|
147
|
+
changelogHeading = new RegExp(`^##\\s*\\[${escapeRegExp(version)}\\](?:\\s|$)`, 'm').test(changelog);
|
|
148
|
+
if (!changelogHeading) {
|
|
149
|
+
failures.push(
|
|
150
|
+
`CHANGELOG.md: missing formal "## [${version}]" heading for the current package version; ` +
|
|
151
|
+
'internal unreleased candidates may be explained in the body but cannot replace the entry',
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
passed: failures.length === 0,
|
|
159
|
+
version,
|
|
160
|
+
markers,
|
|
161
|
+
changelogHeading,
|
|
162
|
+
failures,
|
|
163
|
+
};
|
|
164
|
+
}
|