@starci/hfs 4.7.45 → 4.7.46
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/CHANGELOG.md +5 -0
- package/lint/run.mjs +1 -1
- package/package.json +1 -1
- package/release/candidate-ci.mjs +7 -7
- package/release/push.mjs +3 -3
- package/runtime/config.example.yaml +9 -10
- package/runtime/engine/config.mjs +3 -3
- package/runtime/engine/release-config.mjs +5 -5
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +1 -1
- package/runtime/knowledge/grammars/starci/playbook.yaml +16 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +36 -5
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -0
- package/runtime/modules/kernel/command-policy.yaml +5 -5
- package/runtime/modules/kernel/failure-codes.yaml +32 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +33 -7
- package/runtime/packages/cli/src/catalog.generated.mjs +90 -37
- package/runtime/scripts/guards/command-policy.mjs +4 -4
- package/runtime/scripts/guards/push-scan.mjs +128 -0
- package/runtime/scripts/guards/release-push-gate.mjs +31 -29
- package/runtime/scripts/hfs/example-secrets.mjs +4 -4
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +5 -5
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +61 -0
- package/runtime/scripts/hfs/runtime-rules/hook-shape.mjs +4 -6
- package/runtime/scripts/lib/release-version.mjs +134 -134
- package/runtime/scripts/machine/home.mjs +2 -2
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
- package/templates/app/hooks/husky/pre-push +3 -56
- package/release/supabase-bucket.mjs +0 -8
- package/runtime/scripts/api/fs/write-json-file.mjs +0 -10
- package/runtime/scripts/api/git/merge-base-query.mjs +0 -5
- package/runtime/scripts/gates/commit-proof.mjs +0 -53
- package/runtime/scripts/guards/release-definition.mjs +0 -204
- package/runtime/scripts/guards/release-record.mjs +0 -58
- package/runtime/scripts/guards/release-suite-mode.mjs +0 -15
- package/runtime/scripts/hfs/runtime-rules/release-notes.mjs +0 -44
|
@@ -80,9 +80,9 @@ function hasAgeIdentity(job, doc) {
|
|
|
80
80
|
|
|
81
81
|
/** The release candidate branches (OWNER RULING candidate-ci, 2026-10-18): `starci app release push` waits for the ci.yml run of the release sha there. */
|
|
82
82
|
const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
83
|
-
const pushFilterValid = (options) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
83
|
+
const pushFilterValid = (options, ci) => record(options) && Object.keys(options).length > 0 && Object.keys(options).every((key) => key === 'tags' || key === 'branches')
|
|
84
84
|
&& (options.tags === undefined || (Array.isArray(options.tags) && options.tags.length === 1 && options.tags[0] === 'v*'))
|
|
85
|
-
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length ===
|
|
85
|
+
&& (options.branches === undefined || (Array.isArray(options.branches) && options.branches.length > 0 && new Set(options.branches).size === options.branches.length && options.branches.every((branch) => branch === CANDIDATE_BRANCHES || (ci && branch === 'main'))));
|
|
86
86
|
|
|
87
87
|
function triggerFindings(doc, refuse, file = '') {
|
|
88
88
|
const on = workflowEventOptions(doc.on);
|
|
@@ -94,11 +94,11 @@ function triggerFindings(doc, refuse, file = '') {
|
|
|
94
94
|
for (const [event, options] of Object.entries(on)) {
|
|
95
95
|
if (event === 'workflow_dispatch') continue;
|
|
96
96
|
if (event !== 'push') {
|
|
97
|
-
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches or workflow_dispatch`));
|
|
97
|
+
findings.push(refuse(TRIGGER, `trigger ${event} is forbidden; app CI starts only on release tags [v*], the release candidate branches, main in ci.yml, or workflow_dispatch`));
|
|
98
98
|
continue;
|
|
99
99
|
}
|
|
100
|
-
if (!pushFilterValid(options)) {
|
|
101
|
-
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; other branch and path filters are forbidden`));
|
|
100
|
+
if (!pushFilterValid(options, /(^|\/)ci\.ya?ml$/.test(file))) {
|
|
101
|
+
findings.push(refuse(TRIGGER, `push may contain only tags: [v*] and branches: ['${CANDIDATE_BRANCHES}']; ci.yml may also name main; other branch and path filters are forbidden`));
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
return findings;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// CI_TRIGGERS_RELEASE_ONLY: runtime workflows and app ci.yml may judge main; release candidates, v* tags and manual dispatch retain their scope.
|
|
2
|
+
import { parseWorkflow, workflowFindings } from './workflow-source.mjs';
|
|
3
|
+
import { workflowEventOptions } from '../../lib/workflow-events.mjs';
|
|
4
|
+
|
|
5
|
+
export const CODE = 'CI_TRIGGERS_RELEASE_ONLY';
|
|
6
|
+
const RELEASE_TAGS = 'v*';
|
|
7
|
+
const RUNTIME_BRANCH = 'main';
|
|
8
|
+
/** The branch pattern of the release candidate refs (refs/heads/release/candidate-<version>), shared with the release flows. */
|
|
9
|
+
export const CANDIDATE_BRANCHES = 'release/candidate-*';
|
|
10
|
+
/** The workflows that judge an app release (every app ci.yml): they must run on the candidate refs. The runtime's own workflows judge each push to main instead. */
|
|
11
|
+
const RELEASE_WORKFLOW = /(^|\/)ci\.ya?ml$/;
|
|
12
|
+
|
|
13
|
+
const isReleaseTagFilter = (tags) => Array.isArray(tags) && tags.length === 1 && String(tags[0]) === RELEASE_TAGS;
|
|
14
|
+
/** The branches a filter may name: main (runtime workflows and app ci.yml) and the candidate pattern. */
|
|
15
|
+
const allowedBranches = (ownBranch) => (ownBranch ? [RUNTIME_BRANCH, CANDIDATE_BRANCHES] : [CANDIDATE_BRANCHES]);
|
|
16
|
+
const isBranchFilter = (ownBranch, branches) => Array.isArray(branches) && branches.length > 0 && new Set(branches.map(String)).size === branches.length
|
|
17
|
+
&& branches.every((branch) => allowedBranches(ownBranch).includes(String(branch)));
|
|
18
|
+
const namesCandidate = (branches) => Array.isArray(branches) && branches.map(String).includes(CANDIDATE_BRANCHES);
|
|
19
|
+
|
|
20
|
+
const missingFilterMessage = (ownBranch) => (ownBranch
|
|
21
|
+
? `\`push\` must be filtered to \`tags: ['${RELEASE_TAGS}']\` and/or \`branches\` of [${RUNTIME_BRANCH}, '${CANDIDATE_BRANCHES}'] (no other branch starts CI)`
|
|
22
|
+
: `\`push\` must be filtered to \`tags: ['${RELEASE_TAGS}']\` and/or \`branches: ['${CANDIDATE_BRANCHES}']\` (no other branch starts an app's CI)`);
|
|
23
|
+
|
|
24
|
+
const branchFilterMessage = (ownBranch) => (ownBranch
|
|
25
|
+
? `\`push.branches\` may name only ${RUNTIME_BRANCH} and '${CANDIDATE_BRANCHES}', each once`
|
|
26
|
+
: `\`push.branches\` may name only '${CANDIDATE_BRANCHES}' in an example or an app template: its CI runs on the release candidate and the release tag`);
|
|
27
|
+
|
|
28
|
+
function pushFindings({ ownBranch, config, refuse, release }) {
|
|
29
|
+
const filters = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
|
30
|
+
const { tags, branches } = filters;
|
|
31
|
+
const found = [];
|
|
32
|
+
if (tags === undefined && branches === undefined) found.push(refuse(missingFilterMessage(ownBranch)));
|
|
33
|
+
if (tags !== undefined && !isReleaseTagFilter(tags)) found.push(refuse(`\`push.tags\` must be exactly ['${RELEASE_TAGS}']`));
|
|
34
|
+
if (branches !== undefined && !isBranchFilter(ownBranch, branches)) found.push(refuse(branchFilterMessage(ownBranch)));
|
|
35
|
+
if (release && !namesCandidate(branches)) found.push(refuse(`\`push.branches\` must name '${CANDIDATE_BRANCHES}': starci app release push waits for the CI run of the release sha on its candidate ref`));
|
|
36
|
+
for (const other of Object.keys(filters).filter((key) => key !== 'tags' && key !== 'branches')) found.push(refuse(`\`push.${other}\` is not allowed beside the trigger filters`));
|
|
37
|
+
return found;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function eventFindings({ event, config, ownBranch, refuse, release }) {
|
|
41
|
+
if (event === 'workflow_dispatch') return [];
|
|
42
|
+
if (event !== 'push') return [refuse(`the trigger \`${event}\` is not allowed: only a push of release tags [${RELEASE_TAGS}], of '${CANDIDATE_BRANCHES}' (and, in runtime workflows or app ci.yml, of branch ${RUNTIME_BRANCH}) and workflow_dispatch may start CI`)];
|
|
43
|
+
return pushFindings({ ownBranch, config, refuse, release });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The CI_TRIGGERS_RELEASE_ONLY findings of one workflow text; a text that is not YAML yields none (the other checks name it). Runtime workflows and app ci.yml may also push on main. */
|
|
47
|
+
function workflowTriggerFindings({ path: file, text }) {
|
|
48
|
+
const doc = parseWorkflow(text);
|
|
49
|
+
if (!doc || typeof doc !== 'object') return [];
|
|
50
|
+
const ownBranch = file.startsWith('.github/workflows/') || RELEASE_WORKFLOW.test(file);
|
|
51
|
+
const refuse = (why) => ({ code: CODE, level: 'error', path: file, message: `${CODE} ${file}: ${why}` });
|
|
52
|
+
const on = doc.on;
|
|
53
|
+
if (on === undefined || on === null) return [refuse('the workflow has no `on` trigger block: it must declare push of tags [v*] and/or workflow_dispatch')];
|
|
54
|
+
const events = workflowEventOptions(on, String);
|
|
55
|
+
const release = !file.startsWith('.github/workflows/') && RELEASE_WORKFLOW.test(file);
|
|
56
|
+
const missingPush = release && !Object.hasOwn(events, 'push') ? [refuse(`the app release workflow must run on a push of '${CANDIDATE_BRANCHES}' and of tags [${RELEASE_TAGS}]`)] : [];
|
|
57
|
+
return [...missingPush, ...Object.entries(events).flatMap(([event, config]) => eventFindings({ event, config, ownBranch, refuse, release }))];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** CI_TRIGGERS_RELEASE_ONLY over every tracked workflow (ctx of scripts/hfs/runtime-check.mjs). */
|
|
61
|
+
export const gitTriggerFindings = (ctx) => workflowFindings(ctx, workflowTriggerFindings);
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
// hook-shape.mjs - RT_HOOK_SHAPE (knowledge/hfs/rules.yaml, gate runtime): the app hook templates keep the gate model.
|
|
2
2
|
// pre-commit L0 only: no typecheck and no test run (`test:` scripts, npm test, jest, vitest, a spec). Types and the targeted specs
|
|
3
3
|
// belong to the op gate.
|
|
4
|
-
// pre-push
|
|
5
|
-
// release gate markers: `starci-release` (the L4 record directory), `refs/backup/` (the backup namespace) and `v[0-9]`
|
|
6
|
-
// (the release tag pattern). A push of anything but the release cut and refs/backup/* is refused there.
|
|
4
|
+
// pre-push the bundled push gate owns fast-forward main, annotated tags and the secret scan; no build, test or lint run.
|
|
7
5
|
// Guard hooks keep their host timeout above the runtime's own input, verdict, cleanup and output budgets.
|
|
8
6
|
// Comments are not judged (the templates explain what they do not run). Pure apart from ctx.read.
|
|
9
7
|
import { shellCommandSource } from '../../lib/shell-source.mjs';
|
|
@@ -25,7 +23,7 @@ const PRE_PUSH_FORBIDDEN = [
|
|
|
25
23
|
[/\btypecheck\b/, 'a typecheck'],
|
|
26
24
|
[/\blint\b/, 'a lint run'],
|
|
27
25
|
];
|
|
28
|
-
const PRE_PUSH_MARKERS = [
|
|
26
|
+
const PRE_PUSH_MARKERS = ["node --input-type=module -e", "import { main } from '@starci/hfs/runtime/scripts/guards/release-push-gate.mjs'", 'process.exit(await main())'];
|
|
29
27
|
|
|
30
28
|
const finding = (file, message) => ({ code: CODE, level: 'error', path: file, message: `${CODE} ${file}: ${message}` });
|
|
31
29
|
|
|
@@ -35,13 +33,13 @@ function fileHookShapeFindings({ path: file, name, text }) {
|
|
|
35
33
|
const found = [];
|
|
36
34
|
const forbidden = name === 'pre-commit' ? PRE_COMMIT_FORBIDDEN : PRE_PUSH_FORBIDDEN;
|
|
37
35
|
for (const [pattern, what] of forbidden) {
|
|
38
|
-
if (pattern.test(body)) found.push(finding(file, name === 'pre-commit' ? `the commit gate is L0 only and runs ${what}; types and targeted specs belong to the op gate` : `the push gate
|
|
36
|
+
if (pattern.test(body)) found.push(finding(file, name === 'pre-commit' ? `the commit gate is L0 only and runs ${what}; types and targeted specs belong to the op gate` : `the push gate runs ${what}; it must use the bundled push policy alone`));
|
|
39
37
|
}
|
|
40
38
|
if (name === 'pre-push') {
|
|
41
39
|
const missing = PRE_PUSH_MARKERS.filter((marker) => !body.includes(marker));
|
|
42
40
|
if (missing.length) {
|
|
43
41
|
const markers = missing.map((m) => `\`${m}\``).join(', ');
|
|
44
|
-
found.push(finding(file, `the push gate lacks the
|
|
42
|
+
found.push(finding(file, `the push gate lacks the bundled gate marker${missing.length === 1 ? '' : 's'} ${markers}; it must invoke the shared fast-forward, annotation and secret-scan policy`));
|
|
45
43
|
}
|
|
46
44
|
}
|
|
47
45
|
return found;
|
|
@@ -1,134 +1,134 @@
|
|
|
1
|
-
// release-version.mjs - the one reading of a release version (owner ruling release-waves). A prerelease `<label>.N` opens the release
|
|
2
|
-
// line N and `<label>.N.M` is the M-th release after it, a wave or a fix alike, so alpha.31 < alpha.31.1 < alpha.31.2 < alpha.32 in
|
|
3
|
-
// SemVer order. The next release is derived from the highest one already released, never guessed. The legacy `<label>.N-fix.K` still
|
|
4
|
-
// reads as release K after `<label>.N` and is never produced again. Pure: the callers read the tags and the registry.
|
|
5
|
-
|
|
6
|
-
const NUMERIC = /^(?:0|[1-9]\d*)$/;
|
|
7
|
-
const POSITIVE = /^[1-9]\d*$/;
|
|
8
|
-
const IDENTIFIERS = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
|
|
9
|
-
const LEGACY_FIX = '-fix.';
|
|
10
|
-
|
|
11
|
-
/** {base, wave, legacy}: the release a version counts its waves from, and its wave M (0 for the release that opens the line). */
|
|
12
|
-
export function waveOf(version) {
|
|
13
|
-
const text = String(version ?? '').split('+')[0];
|
|
14
|
-
const fix = text.lastIndexOf(LEGACY_FIX);
|
|
15
|
-
const legacyWave = fix > 0 ? text.slice(fix + LEGACY_FIX.length) : '';
|
|
16
|
-
if (POSITIVE.test(legacyWave)) return { base: text.slice(0, fix), wave: Number(legacyWave), legacy: true };
|
|
17
|
-
const ids = text.split('.');
|
|
18
|
-
const counted = ids.length > 3 && POSITIVE.test(ids.at(-1)) && (text.includes('-') ? NUMERIC.test(ids.at(-2)) : ids.length === 4);
|
|
19
|
-
return counted ? { base: ids.slice(0, -1).join('.'), wave: Number(ids.at(-1)), legacy: false } : { base: text, wave: 0, legacy: false };
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Parsed SemVer identifiers {core, pre} of `version`; an unreadable version throws. */
|
|
23
|
-
export function versionParts(version) {
|
|
24
|
-
const parts = typeof version === 'string' ? version.split('+') : [];
|
|
25
|
-
if (!parts.length || parts.length > 2 || (parts.length === 2 && !IDENTIFIERS.test(parts[1]))) throw new Error(`unreadable release version ${version}`);
|
|
26
|
-
const boundary = parts[0].indexOf('-');
|
|
27
|
-
const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
|
|
28
|
-
const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
|
|
29
|
-
if (core.length !== 3 || core.some((part) => !NUMERIC.test(part)) || (prerelease !== null && !IDENTIFIERS.test(prerelease))) throw new Error(`unreadable release version ${version}`);
|
|
30
|
-
const pre = prerelease?.split('.') ?? [];
|
|
31
|
-
if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
|
|
32
|
-
return { core: core.map(BigInt), pre };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const readable = (version) => {
|
|
36
|
-
try {
|
|
37
|
-
versionParts(version);
|
|
38
|
-
return true;
|
|
39
|
-
} catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
/** The SemVer spelling a version is ordered by: a legacy `-fix.K` orders as wave K of its line. */
|
|
45
|
-
const ordered = (version) => {
|
|
46
|
-
const { base, wave, legacy } = waveOf(version);
|
|
47
|
-
return legacy ? `${base}.${wave}` : version;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
const compareIdentifier = (a, b) => {
|
|
51
|
-
const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
|
|
52
|
-
if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
|
|
53
|
-
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
54
|
-
return a < b ? -1 : Number(a > b);
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/** SemVer precedence, numeric prerelease identifiers included; build metadata has no precedence. */
|
|
58
|
-
export function compareVersions(a, b) {
|
|
59
|
-
const left = versionParts(ordered(a)), right = versionParts(ordered(b));
|
|
60
|
-
for (const i of left.core.keys()) {
|
|
61
|
-
if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
|
|
62
|
-
}
|
|
63
|
-
if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
|
|
64
|
-
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
|
|
65
|
-
if (left.pre[i] === undefined) return -1;
|
|
66
|
-
if (right.pre[i] === undefined) return 1;
|
|
67
|
-
const compared = compareIdentifier(left.pre[i], right.pre[i]);
|
|
68
|
-
if (compared) return compared;
|
|
69
|
-
}
|
|
70
|
-
return 0;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The release line of `version`: a prerelease's core and the identifiers before its first number (every alpha.N and alpha.N.M share one), a stable release's major and minor. */
|
|
74
|
-
export function releaseLine(version) {
|
|
75
|
-
const { core, pre } = versionParts(ordered(version));
|
|
76
|
-
if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
|
|
77
|
-
const first = pre.findIndex((id) => /^\d+$/.test(id));
|
|
78
|
-
return `${core.join('.')}-${(first < 0 ? pre : pre.slice(0, first)).join('.')}`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** {family, line} of a release that opens a line (`<x.y.z>-<label>.N`), or null for any other shape. */
|
|
82
|
-
function lineStart(base) {
|
|
83
|
-
if (!readable(base)) return null;
|
|
84
|
-
const { core, pre } = versionParts(base);
|
|
85
|
-
return pre.length === 2 && !/^\d+$/.test(pre[0]) && NUMERIC.test(pre[1]) ? { family: `${core.join('.')}-${pre[0]}`, line: Number(pre[1]) } : null;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The next wave after `version`: its base with M one past the highest wave of that base among `version` and `known`. */
|
|
89
|
-
export function nextWave(version, known = []) {
|
|
90
|
-
const { base } = waveOf(version);
|
|
91
|
-
const waves = [version, ...known].map(waveOf).filter((entry) => entry.base === base).map((entry) => entry.wave);
|
|
92
|
-
return `${base}.${Math.max(0, ...waves) + 1}`;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** The family of a version: its core and label up to `-<label>` (`1.0.0-alpha`), or null for a stable or unlabelled version. */
|
|
96
|
-
export function familyOf(version) {
|
|
97
|
-
if (!readable(version)) return null;
|
|
98
|
-
const { core, pre } = versionParts(ordered(version));
|
|
99
|
-
return pre.length && !/^\d+$/.test(pre[0]) ? `${core.join('.')}-${pre[0]}` : null;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* What may follow the highest line-shaped release of `versions`: {highest, family, wave, line}, or null when none has that shape.
|
|
104
|
-
* With a `candidate`, a family that already has released tags is judged alone (another family's tags, such as legacy 2.0.1-alpha.N,
|
|
105
|
-
* never outrank the candidate's own line); a family without a tag falls back to the highest release overall.
|
|
106
|
-
*/
|
|
107
|
-
export function nextReleases(versions, candidate = null) {
|
|
108
|
-
const shaped = versions.filter((version) => readable(version) && lineStart(waveOf(version).base));
|
|
109
|
-
const family = candidate === null ? null : familyOf(candidate);
|
|
110
|
-
const own = family === null ? [] : shaped.filter((version) => lineStart(waveOf(version).base).family === family);
|
|
111
|
-
const pool = own.length ? own : shaped;
|
|
112
|
-
if (!pool.length) return null;
|
|
113
|
-
const highest = pool.reduce((top, version) => (compareVersions(version, top) > 0 ? version : top));
|
|
114
|
-
const start = lineStart(waveOf(highest).base);
|
|
115
|
-
return { highest, family: start.family, wave: nextWave(highest, pool), line: `${start.family}.${start.line + 1}` };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Why `version` cannot be released after `released` (the versions already tagged), or null when it can: the next wave of the highest
|
|
120
|
-
* release of its own family, the first release of the next line, or (when its family has no tag) a later release of another label or
|
|
121
|
-
* core than the highest. The legacy `-fix` form is always refused.
|
|
122
|
-
*/
|
|
123
|
-
export function releaseVersionRefusal(version, released) {
|
|
124
|
-
const next = nextReleases(released.filter((entry) => entry !== version), version);
|
|
125
|
-
const named = next ? `; the next release is ${next.wave} (the next wave or fix after ${next.highest}) or ${next.line} (the next line)` : '';
|
|
126
|
-
if (waveOf(version).legacy) return `${version} is the legacy -fix form, which is read and never released again${named}`;
|
|
127
|
-
if (!next || version === next.wave || version === next.line) return null;
|
|
128
|
-
if (!readable(version)) return `${version} is not a SemVer version${named}`;
|
|
129
|
-
if (lineStart(waveOf(version).base)?.family !== next.family && compareVersions(version, next.highest) > 0) return null;
|
|
130
|
-
return `${version} does not follow ${next.highest}${named}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** The versions of the release tags `v<version>` in the output of `git tag --list`. */
|
|
134
|
-
export const taggedVersions = (listing) => String(listing ?? '').split(/\r?\n/).map((name) => name.trim()).filter((name) => /^v\d/.test(name)).map((name) => name.slice(1));
|
|
1
|
+
// release-version.mjs - the one reading of a release version (owner ruling release-waves). A prerelease `<label>.N` opens the release
|
|
2
|
+
// line N and `<label>.N.M` is the M-th release after it, a wave or a fix alike, so alpha.31 < alpha.31.1 < alpha.31.2 < alpha.32 in
|
|
3
|
+
// SemVer order. The next release is derived from the highest one already released, never guessed. The legacy `<label>.N-fix.K` still
|
|
4
|
+
// reads as release K after `<label>.N` and is never produced again. Pure: the callers read the tags and the registry.
|
|
5
|
+
|
|
6
|
+
const NUMERIC = /^(?:0|[1-9]\d*)$/;
|
|
7
|
+
const POSITIVE = /^[1-9]\d*$/;
|
|
8
|
+
const IDENTIFIERS = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
|
|
9
|
+
const LEGACY_FIX = '-fix.';
|
|
10
|
+
|
|
11
|
+
/** {base, wave, legacy}: the release a version counts its waves from, and its wave M (0 for the release that opens the line). */
|
|
12
|
+
export function waveOf(version) {
|
|
13
|
+
const text = String(version ?? '').split('+')[0];
|
|
14
|
+
const fix = text.lastIndexOf(LEGACY_FIX);
|
|
15
|
+
const legacyWave = fix > 0 ? text.slice(fix + LEGACY_FIX.length) : '';
|
|
16
|
+
if (POSITIVE.test(legacyWave)) return { base: text.slice(0, fix), wave: Number(legacyWave), legacy: true };
|
|
17
|
+
const ids = text.split('.');
|
|
18
|
+
const counted = ids.length > 3 && POSITIVE.test(ids.at(-1)) && (text.includes('-') ? NUMERIC.test(ids.at(-2)) : ids.length === 4);
|
|
19
|
+
return counted ? { base: ids.slice(0, -1).join('.'), wave: Number(ids.at(-1)), legacy: false } : { base: text, wave: 0, legacy: false };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Parsed SemVer identifiers {core, pre} of `version`; an unreadable version throws. */
|
|
23
|
+
export function versionParts(version) {
|
|
24
|
+
const parts = typeof version === 'string' ? version.split('+') : [];
|
|
25
|
+
if (!parts.length || parts.length > 2 || (parts.length === 2 && !IDENTIFIERS.test(parts[1]))) throw new Error(`unreadable release version ${version}`);
|
|
26
|
+
const boundary = parts[0].indexOf('-');
|
|
27
|
+
const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
|
|
28
|
+
const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
|
|
29
|
+
if (core.length !== 3 || core.some((part) => !NUMERIC.test(part)) || (prerelease !== null && !IDENTIFIERS.test(prerelease))) throw new Error(`unreadable release version ${version}`);
|
|
30
|
+
const pre = prerelease?.split('.') ?? [];
|
|
31
|
+
if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
|
|
32
|
+
return { core: core.map(BigInt), pre };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const readable = (version) => {
|
|
36
|
+
try {
|
|
37
|
+
versionParts(version);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The SemVer spelling a version is ordered by: a legacy `-fix.K` orders as wave K of its line. */
|
|
45
|
+
const ordered = (version) => {
|
|
46
|
+
const { base, wave, legacy } = waveOf(version);
|
|
47
|
+
return legacy ? `${base}.${wave}` : version;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const compareIdentifier = (a, b) => {
|
|
51
|
+
const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
|
|
52
|
+
if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
|
|
53
|
+
if (numericA !== numericB) return numericA ? -1 : 1;
|
|
54
|
+
return a < b ? -1 : Number(a > b);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** SemVer precedence, numeric prerelease identifiers included; build metadata has no precedence. */
|
|
58
|
+
export function compareVersions(a, b) {
|
|
59
|
+
const left = versionParts(ordered(a)), right = versionParts(ordered(b));
|
|
60
|
+
for (const i of left.core.keys()) {
|
|
61
|
+
if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
|
|
62
|
+
}
|
|
63
|
+
if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
|
|
64
|
+
for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
|
|
65
|
+
if (left.pre[i] === undefined) return -1;
|
|
66
|
+
if (right.pre[i] === undefined) return 1;
|
|
67
|
+
const compared = compareIdentifier(left.pre[i], right.pre[i]);
|
|
68
|
+
if (compared) return compared;
|
|
69
|
+
}
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The release line of `version`: a prerelease's core and the identifiers before its first number (every alpha.N and alpha.N.M share one), a stable release's major and minor. */
|
|
74
|
+
export function releaseLine(version) {
|
|
75
|
+
const { core, pre } = versionParts(ordered(version));
|
|
76
|
+
if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
|
|
77
|
+
const first = pre.findIndex((id) => /^\d+$/.test(id));
|
|
78
|
+
return `${core.join('.')}-${(first < 0 ? pre : pre.slice(0, first)).join('.')}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** {family, line} of a release that opens a line (`<x.y.z>-<label>.N`), or null for any other shape. */
|
|
82
|
+
function lineStart(base) {
|
|
83
|
+
if (!readable(base)) return null;
|
|
84
|
+
const { core, pre } = versionParts(base);
|
|
85
|
+
return pre.length === 2 && !/^\d+$/.test(pre[0]) && NUMERIC.test(pre[1]) ? { family: `${core.join('.')}-${pre[0]}`, line: Number(pre[1]) } : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The next wave after `version`: its base with M one past the highest wave of that base among `version` and `known`. */
|
|
89
|
+
export function nextWave(version, known = []) {
|
|
90
|
+
const { base } = waveOf(version);
|
|
91
|
+
const waves = [version, ...known].map(waveOf).filter((entry) => entry.base === base).map((entry) => entry.wave);
|
|
92
|
+
return `${base}.${Math.max(0, ...waves) + 1}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The family of a version: its core and label up to `-<label>` (`1.0.0-alpha`), or null for a stable or unlabelled version. */
|
|
96
|
+
export function familyOf(version) {
|
|
97
|
+
if (!readable(version)) return null;
|
|
98
|
+
const { core, pre } = versionParts(ordered(version));
|
|
99
|
+
return pre.length && !/^\d+$/.test(pre[0]) ? `${core.join('.')}-${pre[0]}` : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What may follow the highest line-shaped release of `versions`: {highest, family, wave, line}, or null when none has that shape.
|
|
104
|
+
* With a `candidate`, a family that already has released tags is judged alone (another family's tags, such as legacy 2.0.1-alpha.N,
|
|
105
|
+
* never outrank the candidate's own line); a family without a tag falls back to the highest release overall.
|
|
106
|
+
*/
|
|
107
|
+
export function nextReleases(versions, candidate = null) {
|
|
108
|
+
const shaped = versions.filter((version) => readable(version) && lineStart(waveOf(version).base));
|
|
109
|
+
const family = candidate === null ? null : familyOf(candidate);
|
|
110
|
+
const own = family === null ? [] : shaped.filter((version) => lineStart(waveOf(version).base).family === family);
|
|
111
|
+
const pool = own.length ? own : shaped;
|
|
112
|
+
if (!pool.length) return null;
|
|
113
|
+
const highest = pool.reduce((top, version) => (compareVersions(version, top) > 0 ? version : top));
|
|
114
|
+
const start = lineStart(waveOf(highest).base);
|
|
115
|
+
return { highest, family: start.family, wave: nextWave(highest, pool), line: `${start.family}.${start.line + 1}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Why `version` cannot be released after `released` (the versions already tagged), or null when it can: the next wave of the highest
|
|
120
|
+
* release of its own family, the first release of the next line, or (when its family has no tag) a later release of another label or
|
|
121
|
+
* core than the highest. The legacy `-fix` form is always refused.
|
|
122
|
+
*/
|
|
123
|
+
export function releaseVersionRefusal(version, released) {
|
|
124
|
+
const next = nextReleases(released.filter((entry) => entry !== version), version);
|
|
125
|
+
const named = next ? `; the next release is ${next.wave} (the next wave or fix after ${next.highest}) or ${next.line} (the next line)` : '';
|
|
126
|
+
if (waveOf(version).legacy) return `${version} is the legacy -fix form, which is read and never released again${named}`;
|
|
127
|
+
if (!next || version === next.wave || version === next.line) return null;
|
|
128
|
+
if (!readable(version)) return `${version} is not a SemVer version${named}`;
|
|
129
|
+
if (lineStart(waveOf(version).base)?.family !== next.family && compareVersions(version, next.highest) > 0) return null;
|
|
130
|
+
return `${version} does not follow ${next.highest}${named}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The versions of the release tags `v<version>` in the output of `git tag --list`. */
|
|
134
|
+
export const taggedVersions = (listing) => String(listing ?? '').split(/\r?\n/).map((name) => name.trim()).filter((name) => /^v\d/.test(name)).map((name) => name.slice(1));
|
|
@@ -49,7 +49,7 @@ export const STARTUP_RESERVATION_MS = 180_000;
|
|
|
49
49
|
|
|
50
50
|
export const DEFAULTS = Object.freeze({
|
|
51
51
|
workers: Object.freeze({ base: 0, max: 0, hardMax: 10 }),
|
|
52
|
-
landGate: Object.freeze({ mode: 'shared' }),
|
|
52
|
+
landGate: Object.freeze({ mode: 'shared', push: true }),
|
|
53
53
|
frozenMinutes: 10,
|
|
54
54
|
pollIntervalMs: 600_000,
|
|
55
55
|
});
|
|
@@ -194,7 +194,7 @@ export function supervisorSettings({ config = undefined } = {}) {
|
|
|
194
194
|
pollIntervalMs: Number.isInteger(sup.pollIntervalMs) ? sup.pollIntervalMs : DEFAULTS.pollIntervalMs,
|
|
195
195
|
workers: { base: Number.isInteger(sup.workers?.base) ? sup.workers.base : DEFAULTS.workers.base,
|
|
196
196
|
max: Math.min(DEFAULTS.workers.hardMax, Number.isInteger(sup.workers?.max) ? sup.workers.max : DEFAULTS.workers.max) },
|
|
197
|
-
landGate: { mode: sup.landGate?.mode === 'exclusive' ? 'exclusive' : DEFAULTS.landGate.mode },
|
|
197
|
+
landGate: { mode: sup.landGate?.mode === 'exclusive' ? 'exclusive' : DEFAULTS.landGate.mode, push: typeof sup.landGate?.push === 'boolean' ? sup.landGate.push : DEFAULTS.landGate.push },
|
|
198
198
|
// supervisor-watchdog.mjs: a busy seat frame with no turn progress for this long is frozen, not busy.
|
|
199
199
|
frozenMinutes: Number.isInteger(sup.frozenMinutes) && sup.frozenMinutes > 0 ? sup.frozenMinutes : DEFAULTS.frozenMinutes,
|
|
200
200
|
language: typeof cfg?.language === 'string' ? cfg.language : DEFAULT_OWNER_LANGUAGE,
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
name: ci
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# CI judges main, release candidates and release tags.
|
|
5
5
|
on:
|
|
6
6
|
push:
|
|
7
|
-
branches: ["release/candidate-*"]
|
|
7
|
+
branches: ["main", "release/candidate-*"]
|
|
8
8
|
tags: ["v*"]
|
|
9
9
|
workflow_dispatch:
|
|
10
10
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{{header}}
|
|
2
2
|
name: ci
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# CI judges main, release candidates and release tags.
|
|
5
5
|
on:
|
|
6
6
|
push:
|
|
7
|
-
branches: ["release/candidate-*"]
|
|
7
|
+
branches: ["main", "release/candidate-*"]
|
|
8
8
|
tags: ["v*"]
|
|
9
9
|
workflow_dispatch:
|
|
10
10
|
|
|
@@ -1,57 +1,4 @@
|
|
|
1
1
|
{{header}}
|
|
2
|
-
# Push gate:
|
|
3
|
-
#
|
|
4
|
-
|
|
5
|
-
# refs/heads/main, refs/tags/v* allowed only when HEAD carries an annotated tag v[0-9]* and the release cut recorded the
|
|
6
|
-
# full suite of HEAD: <git common dir>/starci-release/<HEAD sha>.l4.json names that head and tag;
|
|
7
|
-
# a pushed tag must be that tag and must point at HEAD
|
|
8
|
-
# refs/heads/release/candidate-* the release candidate (starci app release push sends the release sha there first and moves main
|
|
9
|
-
# and the tag only when its ci.yml run is green): allowed under the same release gate, never deleted
|
|
10
|
-
# any other ref, a delete refused
|
|
11
|
-
common=$(git rev-parse --git-common-dir)
|
|
12
|
-
case "$common" in /* | [A-Za-z]:*) ;; *) common="$(pwd)/$common" ;; esac
|
|
13
|
-
head=$(git rev-parse HEAD)
|
|
14
|
-
record="$common/starci-release/$head.l4.json"
|
|
15
|
-
status=0
|
|
16
|
-
gate=""
|
|
17
|
-
gate_tag=""
|
|
18
|
-
refuse() {
|
|
19
|
-
echo "RIGHTS_PUSH_NOT_RELEASE: $1 - the allowed path is the release cut (main, v* tags and the release/candidate-* ref) or refs/backup/* (backups)" >&2
|
|
20
|
-
status=1
|
|
21
|
-
}
|
|
22
|
-
# gate: the verdict of the release gate for HEAD, computed once: "ok" or the reason it is closed.
|
|
23
|
-
release_gate() {
|
|
24
|
-
if [ -n "$gate" ]; then return 0; fi
|
|
25
|
-
gate="no annotated tag v[0-9]* on HEAD $head"
|
|
26
|
-
tags=$(git tag --points-at HEAD --list 'v[0-9]*' 2>/dev/null) || { gate="cannot list the tags of HEAD $head"; return 0; }
|
|
27
|
-
for tag in $tags; do
|
|
28
|
-
kind=$(git cat-file -t "refs/tags/$tag" 2>/dev/null) || continue
|
|
29
|
-
if [ "$kind" != "tag" ]; then continue; fi
|
|
30
|
-
if [ ! -f "$record" ]; then gate="no L4 record $record"; continue; fi
|
|
31
|
-
flat=$(tr -d ' \t\r\n' < "$record")
|
|
32
|
-
case "$flat" in
|
|
33
|
-
*'"head":"'"$head"'"'*'"tag":"'"$tag"'"'* | *'"tag":"'"$tag"'"'*'"head":"'"$head"'"'*) gate="ok"; gate_tag="$tag"; return 0 ;;
|
|
34
|
-
esac
|
|
35
|
-
gate="the L4 record $record does not name head $head and tag $tag"
|
|
36
|
-
done
|
|
37
|
-
return 0
|
|
38
|
-
}
|
|
39
|
-
while read -r local_ref local_sha remote_ref remote_sha; do
|
|
40
|
-
case "$remote_ref" in
|
|
41
|
-
refs/backup/*) ;;
|
|
42
|
-
refs/heads/main | refs/tags/v* | refs/heads/release/candidate-*)
|
|
43
|
-
case "$local_sha" in
|
|
44
|
-
*[!0]*) ;;
|
|
45
|
-
*) refuse "refusing to delete $remote_ref"; continue ;;
|
|
46
|
-
esac
|
|
47
|
-
release_gate
|
|
48
|
-
if [ "$gate" != "ok" ]; then refuse "$remote_ref is closed: $gate"; continue; fi
|
|
49
|
-
case "$remote_ref" in
|
|
50
|
-
refs/tags/*)
|
|
51
|
-
if [ "$remote_ref" != "refs/tags/$gate_tag" ]; then refuse "$remote_ref is not the release tag $gate_tag of HEAD $head"
|
|
52
|
-
elif [ "$(git rev-parse "$local_sha^{commit}" 2>/dev/null)" != "$head" ]; then refuse "$remote_ref does not point at HEAD $head"; fi ;;
|
|
53
|
-
esac ;;
|
|
54
|
-
*) refuse "refusing to push $remote_ref" ;;
|
|
55
|
-
esac
|
|
56
|
-
done
|
|
57
|
-
exit "$status"
|
|
2
|
+
# Push gate: fast-forward main with a clean secret scan, annotated v* tags, and bounded ref input.
|
|
3
|
+
# The bundled gate owns the policy; backups and lane refs use the same policy as the runtime.
|
|
4
|
+
node --input-type=module -e "import { main } from '@starci/hfs/runtime/scripts/guards/release-push-gate.mjs'; process.exit(await main());"
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// supabase-bucket.mjs - select storage configuration from a local stack's migrated bucket ids.
|
|
2
|
-
|
|
3
|
-
/** Returns no bucket variable for no ids, selects the sole id, and refuses ambiguous storage with every id listed. */
|
|
4
|
-
export function storageBucketEnv(ids) {
|
|
5
|
-
if (ids.length === 0) return {};
|
|
6
|
-
if (ids.length === 1) return { STORAGE_S3_BUCKET: ids[0] };
|
|
7
|
-
throw new Error(`Local Supabase storage has multiple buckets: ${ids.map((id) => JSON.stringify(id)).join(', ')}; app configuration must name which bucket to use`);
|
|
8
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
// write-json-file.mjs - one JSON document written to a file (the runtime's pretty-printed state files).
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
/** `value` written to `file` as two-space JSON plus a trailing newline (parent directories created). */
|
|
6
|
-
export function writeJsonFile(file, value) {
|
|
7
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
8
|
-
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}
|
|
9
|
-
`);
|
|
10
|
-
}
|
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
// merge-base-query.mjs — `git merge-base <args>`: one merge-base query (the best common ancestor, or --is-ancestor's exit status).
|
|
2
|
-
import { runGit } from './lib.mjs';
|
|
3
|
-
|
|
4
|
-
/** The spawn result {status, stdout, stderr, error} of `git merge-base ...args`; options are runGit's (cwd or dir, config, timeout, maxBuffer, input, encoding, env, git). */
|
|
5
|
-
export const mergeBaseQuery = (args = [], options = {}) => runGit(['merge-base', ...args], options);
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
// commit-proof.mjs — locations and file IO for a commit's verification and release evidence. Each producer owns its schema.
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { runtimeStateDir, starciLocalRoot } from '../../engine/runtime-root.mjs';
|
|
5
|
-
import { sha256 } from '../../engine/digest.mjs';
|
|
6
|
-
import { readJsonFile } from '../lib/json.mjs';
|
|
7
|
-
import { sameResolvedPath } from '../lib/path-key.mjs';
|
|
8
|
-
import { writeJsonFile } from '../api/fs/write-json-file.mjs';
|
|
9
|
-
|
|
10
|
-
const RELEASE_KINDS = Object.freeze({ l4: 'l4', affected: 'affected', rows: 'l4-rows', ci: 'ci', report: 'affected-report' });
|
|
11
|
-
const SHORT = 12;
|
|
12
|
-
/** The local git note namespace used to write and read land proof. */
|
|
13
|
-
export const LAND_NOTE_REF = 'land';
|
|
14
|
-
|
|
15
|
-
/** The directory shared by release proofs of every worktree of a repository. */
|
|
16
|
-
const proofDirOf = (commonDir) => path.join(commonDir, 'starci-release');
|
|
17
|
-
|
|
18
|
-
/** The location of an affected, verify, deploy/check or release receipt; existing references keep their established paths. */
|
|
19
|
-
export function proofFileOf({ kind, sha, base, root, env, commonDir }) {
|
|
20
|
-
if (kind === 'verify') return path.join(runtimeStateDir(root), 'verify', `${sha}.json`);
|
|
21
|
-
if (kind === 'affected-receipt') return path.join(runtimeStateDir(root), 'affected', `${String(base).slice(0, SHORT)}-${String(sha).slice(0, SHORT)}.json`);
|
|
22
|
-
if (kind === 'deploy') return path.join(starciLocalRoot(env), 'deploy', 'receipts', `${sha}.json`);
|
|
23
|
-
if (!Object.hasOwn(RELEASE_KINDS, kind)) throw new Error(`unknown commit proof kind: ${kind}`);
|
|
24
|
-
return path.join(proofDirOf(commonDir), `${sha}.${RELEASE_KINDS[kind]}.json`);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** Every release proof of one kind under the git common directory, or [] when the directory is absent. */
|
|
28
|
-
export function proofFilesOf({ commonDir, kind }) {
|
|
29
|
-
if (!Object.hasOwn(RELEASE_KINDS, kind)) throw new Error(`unknown release proof kind: ${kind}`);
|
|
30
|
-
const folder = proofDirOf(commonDir);
|
|
31
|
-
const suffix = `.${RELEASE_KINDS[kind]}.json`;
|
|
32
|
-
return fs.existsSync(folder) ? fs.readdirSync(folder).filter((name) => name.endsWith(suffix)).map((name) => path.join(folder, name)) : [];
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Writes one receipt atomically; a reader sees a complete JSON document. Returns its file. */
|
|
36
|
-
export function writeProofFile(file, record) {
|
|
37
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
38
|
-
writeJsonFile(tmp, record);
|
|
39
|
-
fs.renameSync(tmp, file);
|
|
40
|
-
return file;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** A proof file's parsed document, or null when it is missing, unreadable or malformed; its producer validates the schema. */
|
|
44
|
-
export const readProofFile = (file) => readJsonFile(file, null);
|
|
45
|
-
|
|
46
|
-
/** The digest of the schema's ordered binding fields. */
|
|
47
|
-
export const proofDigest = (fields) => sha256(fields.join('\n'));
|
|
48
|
-
|
|
49
|
-
/** Whether a receipt binds the requested commit and tree. */
|
|
50
|
-
export const proofBinds = (record, { sha, tree }) => record?.sha === sha && record?.tree === tree;
|
|
51
|
-
|
|
52
|
-
/** Whether the specs judged this source root; a missing root never supplies proof for another checkout. */
|
|
53
|
-
export const judgedIn = (recorded, root) => typeof recorded === 'string' && recorded !== '' && sameResolvedPath(recorded, root);
|