@starci/hfs 4.4.0 → 4.5.0
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 +8 -0
- package/README.md +1 -1
- package/ci/cli.mjs +64 -0
- package/ci/github.mjs +41 -0
- package/ci/secret.mjs +28 -0
- package/ci/vars.mjs +33 -0
- package/ci/verify.mjs +36 -0
- package/db/backup-receipt.mjs +129 -0
- package/db/dump.mjs +66 -0
- package/db/migrate.mjs +8 -5
- package/db/post-cutover.mjs +4 -4
- package/db/reset.mjs +125 -0
- package/emit/contracts.mjs +2 -2
- package/emit/db-types.mjs +100 -7
- package/lint/run.mjs +30 -8
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +53 -13
- package/release/index.mjs +8 -6
- package/release/push.mjs +1 -1
- package/report/sonar.mjs +1 -1
- package/runtime/config.example.yaml +7 -6
- package/runtime/engine/config.mjs +10 -18
- package/runtime/engine/removed-vocabulary.mjs +0 -2
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/rules.yaml +505 -971
- package/runtime/knowledge/hfs/slots.yaml +13 -24
- package/runtime/modules/kernel/failure-codes.yaml +60 -22
- package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
- package/runtime/modules/models/runtimes.yaml +4 -6
- package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
- package/runtime/scripts/api/git/lib.mjs +3 -3
- package/runtime/scripts/api/sops/lib.mjs +1 -1
- package/runtime/scripts/hfs/app-wiring.mjs +2 -2
- package/runtime/scripts/hfs/architecture/config.mjs +1 -1
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
- package/runtime/scripts/hfs/architecture.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
- package/runtime/scripts/hfs/coverage-from.mjs +1 -1
- package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
- package/runtime/scripts/hfs/example-secrets.mjs +1 -1
- package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
- package/runtime/scripts/hfs/param-names.mjs +1 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
- package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/assets.mjs +1 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- package/runtime/scripts/hfs/rules/cli.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
- package/runtime/scripts/hfs/rules/contract.mjs +1 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
- package/runtime/scripts/hfs/slots.mjs +2 -2
- package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
- package/runtime/scripts/lib/env.mjs +2 -2
- package/runtime/scripts/lib/git.mjs +1 -1
- package/runtime/scripts/lib/i18n.mjs +0 -2
- package/runtime/scripts/lib/language.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +2 -4
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/scaffold/add-fe-overlay.mjs +0 -121
- package/scaffold/add-fe-page.mjs +0 -268
- package/scaffold/supabase-ports.mjs +1 -1
- package/src/main.mjs +17 -6
- package/stack/age-install.mjs +1 -1
- package/stack/cli.mjs +2 -2
- package/stack/context.mjs +14 -2
- package/stack/digest.mjs +1 -1
- package/stack/docker-transport.mjs +93 -15
- package/stack/edge-alias.mjs +1 -1
- package/stack/image-scan.mjs +1 -1
- package/stack/ingress-create.mjs +2 -2
- package/stack/ingress.mjs +3 -3
- package/stack/release-store.mjs +12 -7
- package/stack/remote.mjs +16 -8
- package/stack/sealed.mjs +5 -5
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +103 -1
- package/sync/format.mjs +1 -1
- package/sync/hygiene.mjs +1 -1
- package/sync/index.mjs +43 -18
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +31 -5
- package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
- package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +21 -0
- package/templates/app/ci-workflows/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/quality-config/codecov.yml +6 -6
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/readme-badges/README.md +1 -0
- package/templates/app/skeleton/.github/dependabot.yml +1 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
- package/templates/app/skeleton/README.md +6 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
- package/upgrade/index.mjs +1 -1
|
@@ -20,7 +20,7 @@ import { withTempEnv } from '../../../engine/temp-root.mjs';
|
|
|
20
20
|
* is a request for raw bytes, not an encoding: spawnSync encodes a string `input` with it and throws
|
|
21
21
|
* ERR_UNKNOWN_ENCODING, so it reaches spawnSync as encoding:null (Buffer output either way).
|
|
22
22
|
*/
|
|
23
|
-
|
|
23
|
+
const gitSpawn = (file, args, options = {}) => {
|
|
24
24
|
assertMutationFence({ kind: 'git-effect', args });
|
|
25
25
|
const spawn = { encoding: 'utf8', windowsHide: true, ...options };
|
|
26
26
|
if (spawn.encoding === 'buffer') spawn.encoding = null;
|
|
@@ -76,5 +76,5 @@ export const gitExecutable = (options = null) => {
|
|
|
76
76
|
return found;
|
|
77
77
|
};
|
|
78
78
|
|
|
79
|
-
/** The output ceiling of the whole-diff and `cat-file --batch` calls
|
|
80
|
-
|
|
79
|
+
/** The output ceiling of the whole-diff and `cat-file --batch` calls batch (a release diff is tens of megabytes). */
|
|
80
|
+
const BATCH_MAX_BUFFER = 1024 * 1024 * 1024;
|
|
@@ -105,7 +105,7 @@ const IDENTITY_TIMEOUT_MS = 20_000;
|
|
|
105
105
|
const CONTROLLED_ENV = new Set(['SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE', 'SOPS_AGE_KEY_CMD', 'SOPS_AGE_SSH_PRIVATE_KEY_FILE', 'SOPS_AGE_SSH_PRIVATE_KEY_CMD', 'SOPS_AGE_RECIPIENT', 'SOPS_KEYSERVICE', 'SOPS_ENABLE_LOCAL_KEYSERVICE', 'SOPS_DECRYPTION_ORDER', 'SOPS_KMS_ARN', 'SOPS_GCP_KMS_IDS', 'SOPS_HUAWEICLOUD_KMS_IDS', 'SOPS_AZURE_KEYVAULT_URLS', 'SOPS_VAULT_URIS', 'SOPS_PGP_FP', 'SOPS_AGE_RECIPIENTS', 'HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME']);
|
|
106
106
|
|
|
107
107
|
/** Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths. */
|
|
108
|
-
|
|
108
|
+
function isolatedSopsEnv(env, inlineName = 'SOPS_AGE_KEY') {
|
|
109
109
|
const out = Object.fromEntries(Object.entries(env).filter(([name]) => !CONTROLLED_ENV.has(name.toUpperCase())));
|
|
110
110
|
out.HOME = ''; out.USERPROFILE = ''; out.APPDATA = ''; out.XDG_CONFIG_HOME = '';
|
|
111
111
|
if (inlineName !== null) out.SOPS_AGE_KEY = env[inlineName];
|
|
@@ -24,7 +24,7 @@ const COMPILATION_FAILURE = 'module or proof could not be compiled';
|
|
|
24
24
|
const WORKER_FAILURE = 'proof worker failed or timed out';
|
|
25
25
|
const SAFE_REASONS = new Set([...DI_FAILURES.values(), COMPILATION_FAILURE]);
|
|
26
26
|
const modulePath = app => `apps/${app}/src/app.module.ts`;
|
|
27
|
-
|
|
27
|
+
const wiringFixturePath = app => `src/tests/fixtures/builders/${app}.builder.ts`;
|
|
28
28
|
|
|
29
29
|
const refusal = (file, reason) => ({ code: BOOT_PROOF_CODE, level: 'error', path: file,
|
|
30
30
|
message: `${file}: lite boot proof failed (${reason}); compile the real AppModule with explicit fixture options and infrastructure doubles using starci app check --wiring`, reason });
|
|
@@ -177,7 +177,7 @@ export function appWiring({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), fil
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
/** Optional standalone entry; coordinator connects the same seam to app check --wiring. */
|
|
180
|
-
|
|
180
|
+
function appWiringMain(argv, { cwd = invocationDir(), stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
|
|
181
181
|
let directory = cwd, json = false;
|
|
182
182
|
for (let at = 0; at < argv.length; at += 1) {
|
|
183
183
|
if (argv[at] === '--cwd' && argv[at + 1]) directory = path.resolve(cwd, argv[++at]);
|
|
@@ -162,7 +162,7 @@ const machineIds = () => [
|
|
|
162
162
|
* backend composition and data machine and frontend repository machine. A spec about another rule judges its own
|
|
163
163
|
* report without these (they have their own specs); every one of them is in ARCHITECTURE_RULE_IDS.
|
|
164
164
|
*/
|
|
165
|
-
|
|
165
|
+
const HFS_MACHINE_RULE_IDS = Object.freeze((() => {
|
|
166
166
|
const ordinary = new Set([...COMMON_RULE_IDS, ...BACKEND_RULE_IDS, ...FRONTEND_RULE_IDS, ...OWNER_RULE_IDS, ...GRAMMAR_RULE_IDS, ...REGISTRATION_RULE_IDS,
|
|
167
167
|
...SWR_DATA_RULE_IDS, SOURCE_LAYOUT_RULE_ID, SOURCE_NAME_RULE_ID, PUBLIC_CONTRACT_RULE_ID, READONLY_BOUNDARY_RULE_ID]);
|
|
168
168
|
return [...new Set(machineIds().filter((id) => !ordinary.has(id)))].sort(byCodeUnit);
|
|
@@ -67,7 +67,7 @@ function finishFrame(state, frame) {
|
|
|
67
67
|
if (state.low.get(frame.node) === state.index.get(frame.node)) closeComponent(state, frame);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
function stronglyConnected(adjacency) {
|
|
71
71
|
const state = { counter: 0, index: new Map(), low: new Map(), onStack: new Set(), stack: [], result: [], work: [] };
|
|
72
72
|
for (const start of adjacency.keys()) {
|
|
73
73
|
if (state.index.has(start)) continue;
|
|
@@ -26,7 +26,7 @@ function parseArchitectureArgs(argv) {
|
|
|
26
26
|
return {root, base};
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
function architectureMain(argv, {check = checkArchitecture, write = (text) => process.stdout.write(text), fail = (text) => process.stderr.write(text)} = {}) {
|
|
30
30
|
let parsed;
|
|
31
31
|
try { parsed = parseArchitectureArgs(argv); } catch (error) { fail(`${error.message}\n`); return 2; }
|
|
32
32
|
let report;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { list } from '../lib/list.mjs';
|
|
3
|
+
import { parseYaml } from '../../engine/yaml.mjs';
|
|
4
|
+
|
|
5
|
+
const nameOf = (item) => typeof item === 'string' ? item : item?.name;
|
|
6
|
+
|
|
7
|
+
/** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
|
|
8
|
+
export function readCiDeclaration(doc) {
|
|
9
|
+
const problems = [], seen = new Set();
|
|
10
|
+
const invalid = (at, message) => problems.push({ code: 'CI_DECLARATION_INVALID', at, message });
|
|
11
|
+
const normalize = (entries, at, dependabot = false, variable = false) => list(entries).map((item, index) => {
|
|
12
|
+
const where = `${at}[${index}]`, name = item?.name;
|
|
13
|
+
if (seen.has(name)) invalid(`${where}.name`, 'a name must appear once across all CI classes');
|
|
14
|
+
seen.add(name);
|
|
15
|
+
if (typeof name !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(name) || name.startsWith('GITHUB_')) invalid(`${where}.name`, 'use an uppercase CI name outside the reserved GITHUB_ namespace');
|
|
16
|
+
const scope = dependabot ? 'repository' : item?.scope;
|
|
17
|
+
if (!['repository', 'environment'].includes(scope)) invalid(`${where}.scope`, 'scope must be repository or environment');
|
|
18
|
+
if (scope === 'environment' && (typeof item?.environment !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.environment))) invalid(`${where}.environment`, 'environment scope requires an environment name');
|
|
19
|
+
if ((dependabot || scope !== 'environment') && item && Object.hasOwn(item, 'environment')) invalid(`${where}.environment`, 'only environment scope may name an environment');
|
|
20
|
+
return { name, scope, environment: item?.environment ?? null, purpose: item?.purpose ?? null,
|
|
21
|
+
optional: item?.optional === true, ...(variable ? { value: item?.value } : {}) };
|
|
22
|
+
});
|
|
23
|
+
const ci = doc?.ci;
|
|
24
|
+
const secrets = normalize(ci?.secrets, 'ci.secrets');
|
|
25
|
+
const vars = normalize(ci?.vars, 'ci.vars', false, true);
|
|
26
|
+
const dependabotSecrets = normalize(ci?.dependabot?.secrets, 'ci.dependabot.secrets', true);
|
|
27
|
+
return { secrets, vars, dependabotSecrets, problems };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Missing quality blocks preserve the existing rendered defaults; disabled services remain explicitly disabled. */
|
|
31
|
+
export function qualityServices(doc, { example = false } = {}) {
|
|
32
|
+
const services = doc?.services ?? {}, sonar = services.sonar, codecov = services.codecov ?? {};
|
|
33
|
+
const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
|
|
34
|
+
return {
|
|
35
|
+
sonar: !sonar || sonar.mode === 'disabled' ? null : {
|
|
36
|
+
provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
|
|
37
|
+
organization: sonar.organization ?? null,
|
|
38
|
+
organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null), projectKey: sonar.projects?.[0]?.key ?? null,
|
|
39
|
+
qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
|
|
40
|
+
token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
|
|
41
|
+
},
|
|
42
|
+
codecov: { enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
|
|
43
|
+
flag: codecov.flag ?? null, target: codecov.target ?? 100, components: codecov.components ?? 'auto', required: codecov.required ?? true, wiring: codecov.ci?.wiring ?? 'required' },
|
|
44
|
+
dependabot: { ecosystems: [...(dependabot.ecosystems ?? ['npm', 'github-actions', 'docker'])],
|
|
45
|
+
schedule: { interval: 'weekly', ...dependabot.schedule }, groups: { minorPatch: 'grouped', major: 'per-package', ...dependabot.groups },
|
|
46
|
+
ignore: [...(dependabot.ignore ?? ['@starci/*'])], security: dependabot.security ?? 'immediate' },
|
|
47
|
+
audit: { level: audit.level ?? 'high', omitDev: audit.omitDev ?? true, in: [...(audit.in ?? ['release-gate', 'ci'])] },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Enumerate a missing directory as empty; all filesystem access uses the caller's injected synchronous seam. */
|
|
52
|
+
function entries(dir, fs) {
|
|
53
|
+
try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
|
|
54
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
|
|
58
|
+
export function sealedKeyNames(root, { fs }) {
|
|
59
|
+
const out = [], base = path.join(root, '.starcistacks');
|
|
60
|
+
for (const env of entries(base, fs).filter((entry) => entry.isDirectory())) {
|
|
61
|
+
for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
|
|
62
|
+
const file = `.starcistacks/${env.name}/secrets/${member.name}`;
|
|
63
|
+
const source = fs.readFileSync(path.join(root, file), 'utf8');
|
|
64
|
+
const dotenv = [...source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
|
|
65
|
+
let keys = dotenv;
|
|
66
|
+
if (!dotenv.length) {
|
|
67
|
+
const doc = parseYaml(source);
|
|
68
|
+
keys = doc && typeof doc === 'object' && !Array.isArray(doc) ? Object.keys(doc).filter((key) => key !== 'sops' && !key.startsWith('sops_')) : [];
|
|
69
|
+
}
|
|
70
|
+
for (const key of keys) out.push({ env: env.name, member: member.name.slice(0, -4), key, file });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Collect every static secrets and vars reference inside workflow expressions, excluding the automatic GitHub token. */
|
|
77
|
+
export function workflowReferences(root, { fs }) {
|
|
78
|
+
const out = [], dir = path.join(root, '.github', 'workflows');
|
|
79
|
+
for (const entry of entries(dir, fs).filter((item) => item.isFile() && /\.ya?ml$/i.test(item.name))) {
|
|
80
|
+
const file = `.github/workflows/${entry.name}`, source = fs.readFileSync(path.join(root, file), 'utf8');
|
|
81
|
+
for (const expression of source.matchAll(/\$\{\{([^}]*)/g)) {
|
|
82
|
+
for (const match of expression[1].matchAll(/\b(secrets|vars)\.([A-Z][A-Z0-9_]*)/g)) {
|
|
83
|
+
if (match[2] !== 'GITHUB_TOKEN') out.push({ file, kind: match[1], name: match[2] });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Service CI bindings name the shared declaration classes, including the readable legacy lists. */
|
|
91
|
+
export function ciNamesOfServices(doc) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const [service, entry] of Object.entries(doc?.services ?? {})) {
|
|
94
|
+
const ci = entry?.ci ?? {};
|
|
95
|
+
if (ci.token) out.push({ service, name: ci.token, kind: 'secret' });
|
|
96
|
+
if (ci.hostVar) out.push({ service, name: ci.hostVar, kind: 'var' });
|
|
97
|
+
if (ci.organizationVar) out.push({ service, name: ci.organizationVar, kind: 'var' });
|
|
98
|
+
for (const item of list(ci.secrets)) out.push({ service, name: nameOf(item), kind: 'secret' });
|
|
99
|
+
for (const item of list(ci.vars)) out.push({ service, name: nameOf(item), kind: 'var' });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
@@ -55,7 +55,7 @@ export function formatCoverageReport(report) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
/** Standalone coordinator entry point: exit 0 proof passed, 2 proof or invocation refused. */
|
|
58
|
-
|
|
58
|
+
function coverageFromMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), run = coverageFrom } = {}) {
|
|
59
59
|
try {
|
|
60
60
|
const opts = parse(argv);
|
|
61
61
|
const report = run({ repoRoot: path.resolve(cwd, opts.cwd ?? '.'), coverageFrom: opts.coverageFrom });
|
|
@@ -17,7 +17,7 @@ import { byCodeUnit } from '../lib/list.mjs';
|
|
|
17
17
|
import { trimTrailingSlashes } from './trailing-slashes.mjs';
|
|
18
18
|
|
|
19
19
|
/** The component that holds the shared infrastructure: the platform tier and every capability more than one service app composes. */
|
|
20
|
-
|
|
20
|
+
const PLATFORM_COMPONENT = 'platform';
|
|
21
21
|
const SERVICE_APP_KINDS = new Set(['api', 'worker']);
|
|
22
22
|
/** `text` with every `<placeholder>` (a `<`, at least one character, the next `>`) replaced by `*`; a `<` with no closing `>` stays. */
|
|
23
23
|
export const star = (text) => {
|
|
@@ -135,7 +135,7 @@ function commandEnvironment() {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/** Run an installed app command in a sealed temporary copy, returning its exit code and preserving report artifacts. */
|
|
138
|
-
|
|
138
|
+
async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
|
|
139
139
|
return withMaterializedExample(repoRoot, '.', (app) => {
|
|
140
140
|
const launch = commandLaunch(command, args);
|
|
141
141
|
const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(), stdio: 'inherit', windowsHide: true });
|
|
@@ -56,8 +56,6 @@ export const unitRolesOf = (manifest) => manifest.ruleParams.be.unitRoles;
|
|
|
56
56
|
/** The roles that carry logic (ruleParams.be.logicRoles): measured at 100 per file inside a coverage-required slot. */
|
|
57
57
|
export const logicRolesOf = (manifest) => manifest.ruleParams.be.logicRoles;
|
|
58
58
|
|
|
59
|
-
/** The roles a feature-tier file is judged thin by (ruleParams.be.thinRoles, R203 BE_FEATURE_THIN). */
|
|
60
|
-
export const thinRolesOf = (manifest) => manifest.ruleParams.be.thinRoles;
|
|
61
59
|
|
|
62
60
|
/** Shape problems of ruleParams.be.unitRoles: unique roles of the suffix vocabulary, each spec `<role>.spec`. */
|
|
63
61
|
export function unitRolesProblems(be) {
|
|
@@ -86,7 +84,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
|
|
|
86
84
|
export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
|
|
87
85
|
const strList = (v) => stringList(v);
|
|
88
86
|
|
|
89
|
-
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
|
|
87
|
+
const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exampleNa', 'exemplifiedBy'];
|
|
90
88
|
/** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
|
|
91
89
|
const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
|
|
92
90
|
/** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
|
|
@@ -220,6 +218,8 @@ function slotCoverageEvidenceProblems(slot, at) {
|
|
|
220
218
|
const bad = [];
|
|
221
219
|
if (slot.templateNa !== undefined && !(typeof slot.templateNa === 'string' && slot.templateNa.trim()))
|
|
222
220
|
bad.push(`${at}: templateNa must be non-empty text`);
|
|
221
|
+
if (slot.exampleNa !== undefined && !(typeof slot.exampleNa === 'string' && slot.exampleNa.trim()))
|
|
222
|
+
bad.push(`${at}: exampleNa must be non-empty text`);
|
|
223
223
|
if (slot.exemplifiedBy === undefined) return bad;
|
|
224
224
|
const refs = slot.exemplifiedBy;
|
|
225
225
|
const valid = Array.isArray(refs) && refs.length > 0 && refs.every(slotEvidenceRefValid)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// param-names.mjs - the one shape of `ruleParams.be.paramNames` of knowledge/hfs/slots.yaml (R85 injected-param-name): a non-empty list of unique
|
|
2
2
|
// entries `{type | typeSuffix, names, nameSuffix?}`. The loader of scripts/hfs/slots.mjs calls it; the schema (modules/schemas/hfs-slots.schema.yaml)
|
|
3
|
-
// states the same shape
|
|
3
|
+
// states the same shape.
|
|
4
4
|
import { isPlainObject } from '../../engine/plain-object.mjs';
|
|
5
5
|
|
|
6
6
|
const UPPER = /^[A-Z][A-Za-z0-9]*$/u;
|
|
@@ -16,7 +16,7 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
|
|
|
16
16
|
const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
|
|
17
17
|
const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
|
|
18
18
|
const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
|
|
19
|
-
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa']);
|
|
19
|
+
const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa', 'specNa', 'exampleNa']);
|
|
20
20
|
const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
|
|
21
21
|
const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
22
|
const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
|
|
@@ -279,6 +279,8 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
|
|
|
279
279
|
if (rule.amendedBy !== undefined) caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
|
|
280
280
|
optionalText(rule, 'editionWhy', label, bad);
|
|
281
281
|
optionalText(rule, 'templateNa', label, bad);
|
|
282
|
+
optionalText(rule, 'specNa', label, bad);
|
|
283
|
+
optionalText(rule, 'exampleNa', label, bad);
|
|
282
284
|
for (const key of ['docs', 'exemplifiedBy', 'proves']) evidenceProblems(rule, key, label, bad);
|
|
283
285
|
witnessProblems(rule, label, bad);
|
|
284
286
|
enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
|
|
@@ -32,17 +32,25 @@ import { stacksIngressFindings } from './stacks-ingress.mjs';
|
|
|
32
32
|
import { stacksLiteBackingFindings } from './stacks-lite-backing.mjs';
|
|
33
33
|
import { stackToolingFindings } from './stack-tooling.mjs';
|
|
34
34
|
import { stacksDeployFindings } from './stacks-deploy.mjs';
|
|
35
|
+
import { ciCustodyFindings } from './ci-custody.mjs';
|
|
36
|
+
import { workflowContextsFindings } from './workflow-contexts.mjs';
|
|
37
|
+
import { feBuildArgsFindings } from './fe-build-args.mjs';
|
|
35
38
|
// finders-import-end
|
|
36
39
|
|
|
37
40
|
/** The failure codes the finders above emit, so `starci app check` ships their labels (check.mjs adds them to CHECK_CODES). Append-only; the coordinator regenerates it at merge time. */
|
|
38
41
|
export const A15_CODES = [
|
|
39
42
|
// codes-begin
|
|
43
|
+
'FE_PUBLIC_BUILD_ARG_MISSING',
|
|
44
|
+
'HFS_WORKFLOW_CONTEXT',
|
|
45
|
+
'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
|
|
40
46
|
'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
|
|
41
47
|
// codes-end
|
|
42
48
|
];
|
|
43
49
|
|
|
44
|
-
|
|
50
|
+
const FINDERS = [
|
|
45
51
|
// finders-begin
|
|
52
|
+
feBuildArgsFindings,
|
|
53
|
+
workflowContextsFindings,
|
|
46
54
|
featureFileFindings,
|
|
47
55
|
lexiconFindings,
|
|
48
56
|
assetsFindings,
|
|
@@ -72,6 +80,7 @@ export const FINDERS = [
|
|
|
72
80
|
stacksLiteBackingFindings,
|
|
73
81
|
stackToolingFindings,
|
|
74
82
|
stacksDeployFindings,
|
|
83
|
+
ciCustodyFindings,
|
|
75
84
|
// finders-end
|
|
76
85
|
];
|
|
77
86
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// app-workflow-findings.mjs - app-root workflow scope shared by deployment and expression-context rules.
|
|
2
|
+
import { workflowFindings } from '../runtime-rules/workflow-source.mjs';
|
|
3
|
+
import { readText } from './read.mjs';
|
|
4
|
+
|
|
5
|
+
/** Judge tracked workflows only at full/lite app roots, preserving each caller's duplicate-file policy. */
|
|
6
|
+
export function appWorkflowFindings({ repoRoot, files, all = files, repo, isRoot, inScope = () => true }, perWorkflow, { dedupe = false } = {}) {
|
|
7
|
+
if (isRoot === false || repo?.kind !== 'app' || !['lite', 'full'].includes(repo.edition)) return [];
|
|
8
|
+
const selected = dedupe ? [...new Set(all)] : all;
|
|
9
|
+
return workflowFindings({ files: selected.filter((file) => inScope(file)), read: (file) => readText(repoRoot, file) }, perWorkflow);
|
|
10
|
+
}
|
|
@@ -8,7 +8,7 @@ import { found } from './read.mjs';
|
|
|
8
8
|
|
|
9
9
|
export const HFS_ASSET_OVERSIZE = 'HFS_ASSET_OVERSIZE';
|
|
10
10
|
export const HFS_ASSET_KIND = 'HFS_ASSET_KIND';
|
|
11
|
-
|
|
11
|
+
const DEFAULT_ASSET_BYTES = 512000;
|
|
12
12
|
const BUILD_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2', '.eot', '.png', '.svg', '.webp', '.jpg', '.jpeg', '.gif', '.ico', '.avif', '.bmp', '.tif', '.tiff'];
|
|
13
13
|
const MEDIA_EXTENSIONS = new Set(['.mp3', '.mp4', '.wav', '.ogg', '.oga', '.opus', '.flac', '.aac', '.m4a', '.wma', '.aiff', '.aif', '.mid', '.midi', '.mov', '.m4v', '.webm', '.avi', '.mkv', '.mpeg', '.mpg', '.wmv', '.flv', '.3gp']);
|
|
14
14
|
const IMAGE_EXTENSIONS = new Set(BUILD_EXTENSIONS.filter((ext) => !['.ttf', '.otf', '.woff', '.woff2', '.eot'].includes(ext)));
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ci-custody.mjs - R296-R298: CI declarations, sealed app keys and workflow reads agree by consumer, without decrypting or contacting GitHub.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { list } from '../../lib/list.mjs';
|
|
4
|
+
import { findStackDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
|
|
5
|
+
import { readCiDeclaration, sealedKeyNames, workflowReferences, ciNamesOfServices } from '../ci-declaration.mjs';
|
|
6
|
+
import { found } from './read.mjs';
|
|
7
|
+
|
|
8
|
+
const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
|
|
9
|
+
const named = (entry) => typeof entry.name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(entry.name);
|
|
10
|
+
const identity = (kind, name) => `${kind}:${name}`;
|
|
11
|
+
const atCi = (section) => `${DECLARATION}#ci.${section}`;
|
|
12
|
+
|
|
13
|
+
/** A CI or Dependabot secret must never also live in a sealed app member. */
|
|
14
|
+
function sealedFindings(ci, sealed) {
|
|
15
|
+
const names = new Set([...ci.secrets, ...ci.dependabotSecrets].filter(named).map((entry) => entry.name));
|
|
16
|
+
return sealed.filter(({ key }) => names.has(key)).map(({ key, file }) => found(
|
|
17
|
+
'CI_SECRET_SEALED', file,
|
|
18
|
+
`${key} is a CI secret (GitHub reads it) but it is sealed in ${file} for app containers; one key, one class: remove it from the member (starci app secret unset ...) and set it with starci app ci secret set ${key}`,
|
|
19
|
+
));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A source selects sealed keys only from its own member and environment. */
|
|
23
|
+
function sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen }) {
|
|
24
|
+
for (const entry of sealed) {
|
|
25
|
+
if (entry.env !== environment || entry.member !== source?.member || !names.has(entry.key)) continue;
|
|
26
|
+
if (source.keys !== undefined && !list(source.keys).includes(entry.key)) continue;
|
|
27
|
+
const id = `${entry.file}:${grant.service}:${entry.key}`;
|
|
28
|
+
if (seen.has(id)) continue;
|
|
29
|
+
seen.add(id);
|
|
30
|
+
findings.push(found('APP_SECRET_IN_CI', entry.file,
|
|
31
|
+
`${entry.key} is read by the ${grant.service} container at runtime through the ${environment} deploy grant; keep it in the .enc and drop it from ci`));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Only keys actually selected by a grant, in that grant's environment and member, are runtime consumers. */
|
|
36
|
+
function runtimeFindings(doc, ci, sealed) {
|
|
37
|
+
const names = new Set([...ci.secrets, ...ci.vars].filter(named).map((entry) => entry.name));
|
|
38
|
+
const findings = [], seen = new Set();
|
|
39
|
+
for (const [environment, spec] of Object.entries(doc.environments ?? {})) {
|
|
40
|
+
for (const grant of list(spec?.deploy?.secrets?.grants)) {
|
|
41
|
+
if (typeof grant?.service !== 'string') continue;
|
|
42
|
+
for (const source of list(grant.from)) sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Workflow reads are class-sensitive; legacy lists remain readable, but an absent ci block always signals migration once per name. */
|
|
49
|
+
function workflowFindings(doc, declared, references) {
|
|
50
|
+
const hasCi = doc.ci !== undefined;
|
|
51
|
+
const allowed = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
52
|
+
if (hasCi) {
|
|
53
|
+
for (const entry of ciNamesOfServices(doc).filter(named)) {
|
|
54
|
+
const legacy = list(doc.services?.[entry.service]?.ci?.[`${entry.kind}s`]);
|
|
55
|
+
if (legacy.some((item) => (typeof item === 'string' ? item : item?.name) === entry.name)) allowed.add(identity(`${entry.kind}s`, entry.name));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const findings = [], seen = new Set();
|
|
59
|
+
for (const { file, kind, name } of references) {
|
|
60
|
+
if (allowed.has(identity(kind, name))) continue;
|
|
61
|
+
const id = hasCi ? `${file}:${kind}:${name}` : name;
|
|
62
|
+
if (seen.has(id)) continue;
|
|
63
|
+
seen.add(id);
|
|
64
|
+
findings.push(found('CI_DECLARATION_DRIFT', file,
|
|
65
|
+
`${kind}.${name} is read by this workflow but is not declared in ci.${kind}; declare its name, scope and consumer in ${DECLARATION}`));
|
|
66
|
+
}
|
|
67
|
+
return findings;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Dependabot has its own store and consumer, so only Actions secrets and vars require a workflow read. */
|
|
71
|
+
function driftFindings(doc, ci, references) {
|
|
72
|
+
const declared = [
|
|
73
|
+
...ci.secrets.filter(named).map((entry) => ({ ...entry, kind: 'secrets' })),
|
|
74
|
+
...ci.vars.filter(named).map((entry) => ({ ...entry, kind: 'vars' })),
|
|
75
|
+
];
|
|
76
|
+
const findings = workflowFindings(doc, declared, references);
|
|
77
|
+
if (doc.ci === undefined) return findings;
|
|
78
|
+
const reads = new Set(references.map(({ kind, name }) => identity(kind, name)));
|
|
79
|
+
for (const { kind, name, optional } of declared) {
|
|
80
|
+
if (!optional && !reads.has(identity(kind, name))) findings.push(found('CI_DECLARATION_DRIFT', atCi(kind),
|
|
81
|
+
`${name} is declared in ci.${kind} as non-optional but no workflow reads it; remove the declaration, mark it optional or wire its workflow consumer`));
|
|
82
|
+
}
|
|
83
|
+
const names = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
84
|
+
for (const [service, entry] of Object.entries(doc.services ?? {})) {
|
|
85
|
+
for (const [field, kind] of [['token', 'secrets'], ['hostVar', 'vars']]) {
|
|
86
|
+
const name = entry?.ci?.[field];
|
|
87
|
+
if (typeof name !== 'string' || names.has(identity(kind, name))) continue;
|
|
88
|
+
findings.push(found('CI_DECLARATION_DRIFT', atCi(kind),
|
|
89
|
+
`services.${service}.ci.${field} names ${name}, which is not declared in ci.${kind}; declare the shared CI name`));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Offline CI custody findings of an app root, in both lite and full editions. */
|
|
96
|
+
export function ciCustodyFindings({ repoRoot, repo, isRoot }) {
|
|
97
|
+
if (repo?.kind !== 'app' || !['lite', 'full'].includes(repo.edition) || isRoot === false) return [];
|
|
98
|
+
const { doc } = findStackDeclaration(repoRoot);
|
|
99
|
+
if (!doc) return [];
|
|
100
|
+
const ci = readCiDeclaration(doc);
|
|
101
|
+
const sealed = sealedKeyNames(repoRoot, { fs });
|
|
102
|
+
const references = workflowReferences(repoRoot, { fs });
|
|
103
|
+
return [...sealedFindings(ci, sealed), ...runtimeFindings(doc, ci, sealed), ...driftFindings(doc, ci, references)];
|
|
104
|
+
}
|
|
@@ -10,7 +10,7 @@ import { found } from './read.mjs';
|
|
|
10
10
|
const CLI_REQUIRED = 'BE_CLI_REQUIRED';
|
|
11
11
|
|
|
12
12
|
/** The one name of the cli app, its kind, and what its image is called below it. */
|
|
13
|
-
|
|
13
|
+
const CLI_APP = 'cli';
|
|
14
14
|
const CLI_KIND = 'cli';
|
|
15
15
|
const IMAGE = 'Dockerfile';
|
|
16
16
|
const CLI_FEATURE_SLOT = 'be.cli';
|
|
@@ -14,7 +14,7 @@ import { isPlainObject } from '../../../engine/plain-object.mjs';
|
|
|
14
14
|
import { found, readJson } from './read.mjs';
|
|
15
15
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
const CONTRACT_BREAKING = 'BE_CONTRACT_BREAKING';
|
|
18
18
|
|
|
19
19
|
const CONTRACTS = /^be\/contracts\/([^/]+)\/events\.pin\.json$/;
|
|
20
20
|
|
|
@@ -15,7 +15,7 @@ import { safeRemove } from '../../api/fs/safe-remove.mjs';
|
|
|
15
15
|
import { makeTempDir } from '../../api/fs/make-temp-dir.mjs';
|
|
16
16
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
|
|
19
19
|
const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
|
|
20
20
|
const SNAPSHOT_OF = { graphql: 'schema.graphql', json: 'openapi.json' };
|
|
21
21
|
const GRAPHQL_PLATFORM = /^src\/modules\/platform\/graphql(?:\/|\.ts$|$)/;
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// R251: product workflow custody, immutable dependencies and release-only triggers, in both editions.
|
|
2
|
-
import { parseWorkflow
|
|
3
|
-
import { found
|
|
2
|
+
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
3
|
+
import { found } from './read.mjs';
|
|
4
|
+
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
4
5
|
import { isPlainObject as record } from '../../../engine/plain-object.mjs';
|
|
5
6
|
import { workflowEventOptions } from '../../lib/workflow-events.mjs';
|
|
6
7
|
|
|
7
8
|
const DEPLOY = 'HFS_DEPLOY_WORKFLOW';
|
|
8
|
-
const PIN = 'HFS_WORKFLOW_PIN';
|
|
9
9
|
const TOOL = 'HFS_CI_TOOL_INSTALL';
|
|
10
10
|
const TRIGGER = 'HFS_APP_WORKFLOW_TRIGGER';
|
|
11
11
|
// Only the age identity and SSH transport credentials belong in the CI secret store.
|
|
@@ -85,18 +85,6 @@ function triggerFindings(doc, refuse) {
|
|
|
85
85
|
return findings;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function actionFindings(doc, refuse) {
|
|
89
|
-
const findings = [];
|
|
90
|
-
for (const { key, value } of entries(doc)) {
|
|
91
|
-
if (key !== 'uses') continue;
|
|
92
|
-
const local = typeof value === 'string' && /^\.\/[\w./-]+$/.test(value);
|
|
93
|
-
const remote = typeof value === 'string' && /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?@[a-f\d]{40}$/i.test(value);
|
|
94
|
-
const container = typeof value === 'string' && /^docker:\/\/[^\s@]+@sha256:[a-f\d]{64}$/i.test(value);
|
|
95
|
-
if (!local && !remote && !container) findings.push(refuse(PIN, 'every remote uses entry needs a full 40-hex commit SHA (a container action needs a sha256 digest)'));
|
|
96
|
-
}
|
|
97
|
-
return findings;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
88
|
function secretProblems(doc) {
|
|
101
89
|
const problems = [];
|
|
102
90
|
if (entries(doc).some(({ key, value }) => key === 'secrets' && value === 'inherit')) problems.push('inherited secrets are forbidden');
|
|
@@ -209,11 +197,10 @@ function perWorkflow({ path: file, text }) {
|
|
|
209
197
|
const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
|
|
210
198
|
const doc = parseWorkflow(text);
|
|
211
199
|
if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
|
|
212
|
-
return [...triggerFindings(doc, refuse), ...
|
|
200
|
+
return [...triggerFindings(doc, refuse), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
213
201
|
}
|
|
214
202
|
|
|
215
203
|
/** Synchronous R251 findings over the tracked workflows of a lite/full app. */
|
|
216
|
-
export function deployWorkflowFindings(
|
|
217
|
-
|
|
218
|
-
return workflowFindings({ files: all.filter((file) => inScope(file)), read: (file) => readText(repoRoot, file) }, perWorkflow);
|
|
204
|
+
export function deployWorkflowFindings(ctx) {
|
|
205
|
+
return appWorkflowFindings(ctx, perWorkflow);
|
|
219
206
|
}
|