@starci/hfs 5.0.5 → 5.0.7
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 +10 -0
- package/README.md +22 -2
- package/cache/session.mjs +1 -1
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +15 -7
- package/ci/cli.d.mts +7 -1
- package/ci/cli.mjs +37 -6
- package/ci/github.d.mts +1 -0
- package/ci/secret.d.mts +10 -1
- package/ci/secret.mjs +26 -6
- package/ci/verify.d.mts +9 -1
- package/ci/verify.mjs +5 -3
- package/package.json +1 -1
- package/release/candidate-ci.mjs +1 -1
- package/release/gate.d.mts +1 -0
- package/release/gate.mjs +76 -18
- package/release/index.d.mts +1 -1
- package/release/index.mjs +7 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +10 -7
- package/runtime/scripts/gates/starcistacks-ci.mjs +3 -1
- package/runtime/scripts/gates/starcistacks-form.d.mts +1 -0
- package/runtime/scripts/gates/starcistacks.d.mts +1 -0
- package/runtime/scripts/gates/starcistacks.mjs +22 -10
- package/runtime/scripts/hfs/architecture/contract-bounds.d.mts +3 -0
- package/runtime/scripts/hfs/architecture/contract-bounds.mjs +72 -0
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.d.mts +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +45 -4
- package/runtime/scripts/hfs/ci-declaration.d.mts +47 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +115 -3
- package/runtime/scripts/hfs/repo-identity.d.mts +5 -1
- package/runtime/scripts/hfs/repo-identity.mjs +15 -10
- package/runtime/scripts/hfs/rules/ci-custody.mjs +21 -32
- package/runtime/scripts/hfs/rules/env-parity.mjs +15 -6
- package/runtime/scripts/hfs/secret-standard.d.mts +1 -0
- package/runtime/scripts/hfs/secret-standard.mjs +25 -4
- package/runtime/scripts/hfs/secret-store.d.mts +5 -0
- package/runtime/scripts/hfs/secret-store.mjs +31 -0
- package/src/main.mjs +1 -1
- package/sync/ci-commands.mjs +2 -1
- package/sync/quality-services.mjs +3 -2
- package/sync/readme-badges.mjs +1 -1
- package/sync/sonar-key.d.mts +2 -3
- package/sync/sonar-key.mjs +7 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +7 -3
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +7 -3
|
@@ -5,6 +5,114 @@ import { parseYaml } from '../../engine/yaml.mjs';
|
|
|
5
5
|
/** True when readCiDeclaration found no problem in `declaration`. */
|
|
6
6
|
export const isValidCiDeclaration = (declaration) => declaration.problems.length === 0;
|
|
7
7
|
const nameOf = (item) => typeof item === 'string' ? item : item?.name;
|
|
8
|
+
/** A private endpoint uses an explicitly linked secret copy; public endpoints use the existing CI variable selector. */
|
|
9
|
+
export function serviceHostSecret(service) {
|
|
10
|
+
const name = service?.ci?.hostVar || 'SONAR_HOST_URL';
|
|
11
|
+
return service?.host?.fromCredential && list(service?.ci?.secrets).some((link) => link?.name === name && link?.credential === service.host.fromCredential) ? name : null;
|
|
12
|
+
}
|
|
13
|
+
/** Public credential selectors that ordinary child processes have no authority to inherit. */
|
|
14
|
+
export function serviceCredentialNames(doc) {
|
|
15
|
+
return [...new Set(Object.values(doc?.services ?? {}).flatMap((service) => [service?.ci?.token,
|
|
16
|
+
...(service?.host?.fromCredential ? [service?.ci?.hostVar] : []), ...list(service?.ci?.secrets).map((entry) => entry?.name),
|
|
17
|
+
...list(service?.credentials).flatMap((credential) => [credential?.env, credential?.key])]).filter((name) => typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)))];
|
|
18
|
+
}
|
|
19
|
+
const OPERATOR_CREDENTIAL = /(?:^|-)(?:admin|operator|provision|provisioning|global)(?:-|$)/iu;
|
|
20
|
+
/** An application runtime declaration or deploy grant selects runtime authority regardless of its CI name. */
|
|
21
|
+
export function runtimeGranted(doc, environment, member, key) {
|
|
22
|
+
const spec = doc?.environments?.[environment];
|
|
23
|
+
if (list(spec?.secrets).some((entry) => (entry?.member ?? /^secrets\/([a-z0-9-]+)/u.exec(entry?.where ?? '')?.[1] ?? entry?.name) === member
|
|
24
|
+
&& (entry.key === key || (entry.key === undefined && (entry.keys === undefined || list(entry.keys).includes(key))))))
|
|
25
|
+
return true;
|
|
26
|
+
return list(spec?.deploy?.secrets?.grants).some((grant) => typeof grant?.service === 'string'
|
|
27
|
+
&& list(grant.from).some((source) => source?.member === member && (source.keys === undefined || list(source.keys).includes(key))));
|
|
28
|
+
}
|
|
29
|
+
/** Read an actual member path, retaining the legacy dotenv suffix without guessing a plaintext twin. */
|
|
30
|
+
export function serviceMemberPath(value) {
|
|
31
|
+
if (typeof value !== 'string')
|
|
32
|
+
return null;
|
|
33
|
+
const match = /^\.starcistacks\/([a-z][a-z0-9-]*)\/secrets\/([a-z][a-z0-9-]*)\.enc(?:\.yaml)?$/u.exec(value);
|
|
34
|
+
return match ? { environment: match[1], member: match[2], path: value } : null;
|
|
35
|
+
}
|
|
36
|
+
/** The selected project's public credential reference, independent of CI: used for a sealed private endpoint too. */
|
|
37
|
+
export function projectServiceCredential(doc, serviceId, credentialId, repository) {
|
|
38
|
+
const service = doc?.services?.[serviceId], projects = list(service?.projects);
|
|
39
|
+
const selected = projects.filter((project) => project?.repository === repository);
|
|
40
|
+
const credentials = list(service?.credentials).filter((item) => item?.id === credentialId);
|
|
41
|
+
const credential = credentials.length === 1 ? credentials[0] : null, member = serviceMemberPath(credential?.custody?.path);
|
|
42
|
+
if (selected.length !== 1 || typeof selected[0]?.key !== 'string' || !selected[0].key || credential?.project !== selected[0].key
|
|
43
|
+
|| projects.filter((project) => project?.key === credential?.project).length !== 1
|
|
44
|
+
|| !member || credential.custody.repository !== repository || !Object.hasOwn(doc?.environments ?? {}, member.environment)
|
|
45
|
+
|| typeof credential.key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(credential.key)
|
|
46
|
+
|| OPERATOR_CREDENTIAL.test(credentialId)
|
|
47
|
+
|| runtimeGranted(doc, member.environment, member.member, credential.key))
|
|
48
|
+
return null;
|
|
49
|
+
// A tooling member carries one authority: another project's or the operator's credential cannot share its envelope.
|
|
50
|
+
if (list(service.credentials).some((item) => item !== credential && item?.custody?.repository === repository
|
|
51
|
+
&& item?.custody?.path === member.path && (item?.project !== credential.project || OPERATOR_CREDENTIAL.test(String(item?.id)))))
|
|
52
|
+
return null;
|
|
53
|
+
return { service: serviceId, credential: credentialId, name: '', repository, projectKey: selected[0].key,
|
|
54
|
+
environment: member.environment, member: member.member, key: credential.key, path: member.path };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Positive tooling classification: the selected product project, one explicit CI link, its own declared environment and exact
|
|
58
|
+
* member/key. Names alone, a sealed key alone, or credentials of a host or another project confer no tooling authority.
|
|
59
|
+
*/
|
|
60
|
+
export function serviceBindings(doc, repository) {
|
|
61
|
+
const bindings = [], problems = [];
|
|
62
|
+
const bad = (at, message) => { problems.push({ at, message }); };
|
|
63
|
+
for (const [id, service] of Object.entries(doc?.services ?? {})) {
|
|
64
|
+
const links = list(service?.ci?.secrets);
|
|
65
|
+
const at = `services.${id}`;
|
|
66
|
+
if (id === 'codecov' && service?.auth === 'oidc' && (service.ci?.token || links.length || list(service.credentials).length))
|
|
67
|
+
bad(at, 'OIDC Codecov declares no stored token, credential or CI secret copy');
|
|
68
|
+
if (id === 'sonar' && service?.mode && service.mode !== 'disabled' && service.ci?.wiring === 'required'
|
|
69
|
+
&& (!links.length || typeof service.ci.token !== 'string'))
|
|
70
|
+
bad(at, 'required product Sonar declares its analysis credential and CI binding to a sonar.enc.yaml member');
|
|
71
|
+
if (service?.host?.fromCredential && service.ci?.wiring === 'required' && !serviceHostSecret(service))
|
|
72
|
+
bad(`${at}.host.fromCredential`, 'a required private endpoint has an explicit credential-to-CI secret copy selected by ci.hostVar');
|
|
73
|
+
if (!links.length)
|
|
74
|
+
continue;
|
|
75
|
+
if (service.ci.token && !links.some((link) => link?.name === service.ci.token)) {
|
|
76
|
+
bad(`${at}.ci.token`, 'ci.token must select an explicit credential copy');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const link of links) {
|
|
80
|
+
const where = `${at}.ci.secrets`;
|
|
81
|
+
if (service?.mode === 'disabled' || service?.auth !== 'token') {
|
|
82
|
+
bad(where, 'credential copies require an enabled token-authenticated service');
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const references = list(service.credentials).filter((credential) => credential?.id === link?.credential);
|
|
86
|
+
const other = references.length === 1 && list(service.projects).find((project) => project?.key === references[0]?.project);
|
|
87
|
+
if (other && other.repository !== repository)
|
|
88
|
+
continue;
|
|
89
|
+
const binding = typeof link?.credential === 'string' ? projectServiceCredential(doc, id, link.credential, repository) : null;
|
|
90
|
+
if (!binding || typeof link?.name !== 'string' || !/^[A-Z][A-Z0-9_]*$/u.test(link.name)
|
|
91
|
+
|| links.filter((item) => item?.name === link.name).length !== 1) {
|
|
92
|
+
bad(where, 'each unique CI copy selects one project credential in its own declared environment/member/key, separate from operator and app runtime authority');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (id === 'sonar' && link.name === service.ci.token && binding.path !== `.starcistacks/${binding.environment}/secrets/sonar.enc.yaml`) {
|
|
96
|
+
bad(where, 'Sonar analysis custody is the product environment\'s sonar.enc.yaml member');
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
bindings.push({ ...binding, name: link.name });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const binding of [...bindings]) {
|
|
103
|
+
if (bindings.filter((item) => item.name === binding.name).length > 1)
|
|
104
|
+
bad('services', `CI secret ${binding.name} has ambiguous service custody`);
|
|
105
|
+
}
|
|
106
|
+
const sonar = doc?.services?.sonar;
|
|
107
|
+
if (sonar?.mode && sonar.mode !== 'disabled' && sonar.ci?.wiring === 'required'
|
|
108
|
+
&& !bindings.some((binding) => binding.service === 'sonar' && binding.name === sonar.ci.token))
|
|
109
|
+
bad('services.sonar', 'required Sonar analysis has no credential binding for the selected product project');
|
|
110
|
+
return { bindings: bindings.filter((binding) => bindings.filter((item) => item.name === binding.name).length === 1), problems };
|
|
111
|
+
}
|
|
112
|
+
/** A sealed row belongs to this exact positive binding; a same-named key elsewhere remains unrelated custody. */
|
|
113
|
+
export function boundSealedKey(binding, sealed) {
|
|
114
|
+
return binding.environment === sealed.env && binding.member === sealed.member && binding.key === sealed.key && binding.path === sealed.file;
|
|
115
|
+
}
|
|
8
116
|
/** A public CI variable declares either a manifest value or an owner-supplied value, never both. */
|
|
9
117
|
function variableProblems(item, at, invalid) {
|
|
10
118
|
const hasValue = item != null && Object.hasOwn(item, 'value');
|
|
@@ -44,14 +152,18 @@ export function readCiDeclaration(doc) {
|
|
|
44
152
|
return { secrets, vars, dependabotSecrets, problems };
|
|
45
153
|
}
|
|
46
154
|
/** Missing quality blocks preserve the existing rendered defaults; disabled services remain explicitly disabled. */
|
|
47
|
-
export function qualityServices(doc, { example = false } = {}) {
|
|
155
|
+
export function qualityServices(doc, { example = false, repository } = {}) {
|
|
48
156
|
const services = doc?.services ?? {}, sonar = services.sonar, codecov = services.codecov ?? {};
|
|
49
157
|
const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
|
|
50
158
|
return {
|
|
51
159
|
sonar: !sonar || sonar.mode === 'disabled' ? null : {
|
|
52
160
|
provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
|
|
161
|
+
hostCredential: sonar.host?.fromCredential ?? null,
|
|
162
|
+
hostSecret: serviceHostSecret(sonar),
|
|
53
163
|
organization: sonar.organization ?? null,
|
|
54
|
-
organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null),
|
|
164
|
+
organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null),
|
|
165
|
+
projectKey: (repository ? list(sonar.projects).find((project) => project?.repository === repository) : sonar.projects?.[0])?.key ?? null,
|
|
166
|
+
credentials: sonar.credentials ?? [], projects: sonar.projects ?? [], auth: sonar.auth, ci: sonar.ci ?? {},
|
|
55
167
|
qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
|
|
56
168
|
token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
|
|
57
169
|
},
|
|
@@ -115,7 +227,7 @@ export function ciNamesOfServices(doc) {
|
|
|
115
227
|
if (ci.token)
|
|
116
228
|
out.push({ service, name: ci.token, kind: 'secret' });
|
|
117
229
|
if (ci.hostVar)
|
|
118
|
-
out.push({ service, name: ci.hostVar, kind: 'var' });
|
|
230
|
+
out.push({ service, name: ci.hostVar, kind: entry?.host?.fromCredential ? 'secret' : 'var' });
|
|
119
231
|
if (ci.organizationVar)
|
|
120
232
|
out.push({ service, name: ci.organizationVar, kind: 'var' });
|
|
121
233
|
for (const item of list(ci.secrets))
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Identity queries need repository configuration, never age identities or selected service connection credentials. */
|
|
2
|
+
export declare function identityQueryEnvironment(parent: NodeJS.ProcessEnv, privateNames?: readonly string[]): NodeJS.ProcessEnv;
|
|
1
3
|
/** What git says about a folder. */
|
|
2
4
|
export interface RepoIdentity {
|
|
3
5
|
inWorkTree: boolean;
|
|
@@ -10,6 +12,8 @@ export interface RepoIdentity {
|
|
|
10
12
|
* name outside Git, else the folder name. A folder inside a larger repository (a nested product, an example) is named by
|
|
11
13
|
* its own folder.
|
|
12
14
|
*/
|
|
13
|
-
export declare function repositoryName(root: string
|
|
15
|
+
export declare function repositoryName(root: string, { env }?: {
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
}): string;
|
|
14
18
|
/** The folder whose siblings are this repository's sibling repositories: the main checkout for a worktree, else root. */
|
|
15
19
|
export declare function repositoryHome(root: string): string;
|
|
@@ -5,10 +5,15 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { revParseQuery } from '../api/git/rev-parse-query.mjs';
|
|
7
7
|
import { remote as gitRemote } from '../api/git/remote.mjs';
|
|
8
|
-
import { gitOutputOf } from '../lib/git.mjs';
|
|
8
|
+
import { gitOutputOf, withoutGitLocalEnv } from '../lib/git.mjs';
|
|
9
9
|
import { trimTrailingSlashes } from './trailing-slashes.mjs';
|
|
10
10
|
/** One git call (a scripts/api/git call file) in `root`: its trimmed stdout; throws unless git exits 0. */
|
|
11
|
-
const git = (call, root, args) => gitOutputOf(call(args, { cwd: root })).trim(); // gitOutputOf threw unless git exited 0, whose stdout is text
|
|
11
|
+
const git = (call, root, args, env) => gitOutputOf(call(args, { cwd: root, env: identityQueryEnvironment(env) })).trim(); // gitOutputOf threw unless git exited 0, whose stdout is text
|
|
12
|
+
/** Identity queries need repository configuration, never age identities or selected service connection credentials. */
|
|
13
|
+
export function identityQueryEnvironment(parent, privateNames = []) {
|
|
14
|
+
const selected = new Set(privateNames.map((name) => name.toUpperCase()));
|
|
15
|
+
return Object.fromEntries(Object.entries(withoutGitLocalEnv(parent)).filter(([name]) => !selected.has(name.toUpperCase()) && !/^(?:SOPS_AGE_KEY(?:_FILE)?|SONAR_.*)$/iu.test(name)));
|
|
16
|
+
}
|
|
12
17
|
const real = (p) => { try {
|
|
13
18
|
return fs.realpathSync(p);
|
|
14
19
|
}
|
|
@@ -26,20 +31,20 @@ catch {
|
|
|
26
31
|
return null;
|
|
27
32
|
} };
|
|
28
33
|
/** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
|
|
29
|
-
function identityOf(root) {
|
|
34
|
+
function identityOf(root, env = process.env) {
|
|
30
35
|
const state = stateOf(root);
|
|
31
36
|
if (state === null)
|
|
32
|
-
return askGit(root);
|
|
37
|
+
return askGit(root, env);
|
|
33
38
|
if (!known.has(state))
|
|
34
|
-
known.set(state, askGit(root));
|
|
39
|
+
known.set(state, askGit(root, env));
|
|
35
40
|
return known.get(state);
|
|
36
41
|
}
|
|
37
|
-
function askGit(root) {
|
|
42
|
+
function askGit(root, env) {
|
|
38
43
|
let inWorkTree = false, repositoryRoot = false, home = null;
|
|
39
44
|
try {
|
|
40
45
|
// Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
|
|
41
46
|
// One git call answers both questions: the top level on the first line, the common git dir on the second.
|
|
42
|
-
const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir']).split(String.fromCodePoint(10)).map((line) => line.trim());
|
|
47
|
+
const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir'], env).split(String.fromCodePoint(10)).map((line) => line.trim());
|
|
43
48
|
repositoryRoot = real(top) === real(root);
|
|
44
49
|
inWorkTree = true;
|
|
45
50
|
const common = path.resolve(root, commonDir);
|
|
@@ -55,11 +60,11 @@ function askGit(root) {
|
|
|
55
60
|
* name outside Git, else the folder name. A folder inside a larger repository (a nested product, an example) is named by
|
|
56
61
|
* its own folder.
|
|
57
62
|
*/
|
|
58
|
-
export function repositoryName(root) {
|
|
59
|
-
const { inWorkTree, repositoryRoot, home } = identityOf(root);
|
|
63
|
+
export function repositoryName(root, { env = process.env } = {}) {
|
|
64
|
+
const { inWorkTree, repositoryRoot, home } = identityOf(root, env);
|
|
60
65
|
if (repositoryRoot) {
|
|
61
66
|
try {
|
|
62
|
-
const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'])).replace(GIT_SUFFIX, '');
|
|
67
|
+
const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'], env)).replace(GIT_SUFFIX, '');
|
|
63
68
|
const name = remote.split(/[/:]/u).pop();
|
|
64
69
|
if (name)
|
|
65
70
|
return name;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { list } from '../../lib/list.mjs';
|
|
4
4
|
import { findStackDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
|
|
5
|
-
import { readCiDeclaration, sealedKeyNames, workflowReferences, ciNamesOfServices } from '../ci-declaration.mjs';
|
|
5
|
+
import { readCiDeclaration, sealedKeyNames, workflowReferences, ciNamesOfServices, serviceBindings, boundSealedKey, runtimeGranted, serviceCredentialNames } from '../ci-declaration.mjs';
|
|
6
|
+
import { repositoryName, identityQueryEnvironment } from '../repo-identity.mjs';
|
|
6
7
|
import { found } from './read.mjs';
|
|
7
8
|
const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
|
|
8
9
|
const named = (entry) => typeof entry.name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(entry.name);
|
|
@@ -10,38 +11,18 @@ const identity = (kind, name) => `${kind}:${name}`;
|
|
|
10
11
|
const atCi = (section) => `${DECLARATION}#ci.${section}`;
|
|
11
12
|
/** The variables the managed workflows themselves read; an app declares none of them. STARCI_DEPLOY_PAUSED is the brake of the automatic deploy, set per GitHub environment by the owner. */
|
|
12
13
|
const MANAGED_VARIABLES = ['STARCI_DEPLOY_PAUSED'];
|
|
13
|
-
/** A CI
|
|
14
|
-
function sealedFindings(ci, sealed) {
|
|
14
|
+
/** A sealed CI copy has exact service/project custody; every other sealed CI key remains refused. */
|
|
15
|
+
function sealedFindings(ci, sealed, bindings) {
|
|
15
16
|
const names = new Set([...ci.secrets, ...ci.dependabotSecrets].filter(named).map((entry) => entry.name));
|
|
16
|
-
|
|
17
|
+
const copies = bindings.filter((binding) => names.has(binding.name));
|
|
18
|
+
return sealed.filter((entry) => (names.has(entry.key) || copies.some((binding) => binding.key === entry.key))
|
|
19
|
+
&& !copies.some((binding) => boundSealedKey(binding, entry))).map(({ key, file }) => found('CI_SECRET_SEALED', file, `${key} is a CI secret sealed in ${file} without an exact project service binding; app runtime keys stay in their members and tooling copies name services.<id>.ci.secrets[].credential`));
|
|
17
20
|
}
|
|
18
|
-
/**
|
|
19
|
-
function sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen }) {
|
|
20
|
-
for (const entry of sealed) {
|
|
21
|
-
if (entry.env !== environment || entry.member !== source?.member || !names.has(entry.key))
|
|
22
|
-
continue;
|
|
23
|
-
if (source.keys !== undefined && !list(source.keys).includes(entry.key))
|
|
24
|
-
continue;
|
|
25
|
-
const id = `${entry.file}:${grant.service}:${entry.key}`;
|
|
26
|
-
if (seen.has(id))
|
|
27
|
-
continue;
|
|
28
|
-
seen.add(id);
|
|
29
|
-
findings.push(found('APP_SECRET_IN_CI', entry.file, `${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`));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/** Only keys actually selected by a grant, in that grant's environment and member, are runtime consumers. */
|
|
21
|
+
/** Runtime declarations and grants select exact environment/member/key consumers; an unselected key is unrelated. */
|
|
33
22
|
function runtimeFindings(doc, ci, sealed) {
|
|
34
23
|
const names = new Set([...ci.secrets, ...ci.vars].filter(named).map((entry) => entry.name));
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const grant of list(spec?.deploy?.secrets?.grants)) {
|
|
38
|
-
if (typeof grant?.service !== 'string')
|
|
39
|
-
continue;
|
|
40
|
-
for (const source of list(grant.from))
|
|
41
|
-
sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return findings;
|
|
24
|
+
return sealed.filter((entry) => names.has(entry.key) && runtimeGranted(doc, entry.env, entry.member, entry.key))
|
|
25
|
+
.map((entry) => found('APP_SECRET_IN_CI', entry.file, `${entry.key} is selected by an app runtime declaration or deploy grant in ${entry.env}; keep it sealed and drop it from ci`));
|
|
45
26
|
}
|
|
46
27
|
/** Workflow reads are class-sensitive; legacy lists remain readable, but an absent ci block always signals migration once per name. */
|
|
47
28
|
function workflowFindings(doc, declared, references) {
|
|
@@ -50,7 +31,7 @@ function workflowFindings(doc, declared, references) {
|
|
|
50
31
|
if (hasCi) {
|
|
51
32
|
for (const entry of ciNamesOfServices(doc).filter(named)) {
|
|
52
33
|
const legacy = list(doc.services?.[entry.service]?.ci?.[`${entry.kind}s`]);
|
|
53
|
-
if (legacy.some((item) => (typeof item === 'string' ? item : item?.name) === entry.name))
|
|
34
|
+
if (legacy.some((item) => (typeof item === 'string' ? item : !item?.credential ? item?.name : null) === entry.name))
|
|
54
35
|
allowed.add(identity(`${entry.kind}s`, entry.name));
|
|
55
36
|
}
|
|
56
37
|
}
|
|
@@ -82,7 +63,11 @@ function driftFindings(doc, ci, references) {
|
|
|
82
63
|
}
|
|
83
64
|
const names = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
84
65
|
for (const [service, entry] of Object.entries(doc.services ?? {})) {
|
|
85
|
-
for (const
|
|
66
|
+
for (const binding of list(entry?.ci?.secrets)) {
|
|
67
|
+
if (typeof binding?.credential === 'string' && !names.has(identity('secrets', binding.name)))
|
|
68
|
+
findings.push(found('CI_DECLARATION_DRIFT', atCi('secrets'), `services.${service}.ci.secrets names ${binding.name}, which is not declared in ci.secrets`));
|
|
69
|
+
}
|
|
70
|
+
for (const [field, kind] of [['token', 'secrets'], ['hostVar', entry?.host?.fromCredential ? 'secrets' : 'vars']]) {
|
|
86
71
|
const name = entry?.ci?.[field];
|
|
87
72
|
if (typeof name !== 'string' || names.has(identity(kind, name)))
|
|
88
73
|
continue;
|
|
@@ -99,7 +84,11 @@ export function ciCustodyFindings({ repoRoot, repo, isRoot }) {
|
|
|
99
84
|
if (!doc)
|
|
100
85
|
return [];
|
|
101
86
|
const ci = readCiDeclaration(doc);
|
|
87
|
+
const selected = serviceBindings(doc, repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(doc)) }));
|
|
102
88
|
const sealed = sealedKeyNames(repoRoot, { fs });
|
|
103
89
|
const references = workflowReferences(repoRoot, { fs });
|
|
104
|
-
|
|
90
|
+
const missing = selected.bindings.filter((binding) => !sealed.some((entry) => boundSealedKey(binding, entry)))
|
|
91
|
+
.map((binding) => found('CI_SECRET_SEALED', binding.path, 'the selected service credential member/key is not sealed in this product custody'));
|
|
92
|
+
return [...selected.problems.map((problem) => found('CI_SECRET_SEALED', `${DECLARATION}#${problem.at}`, problem.message)), ...missing,
|
|
93
|
+
...sealedFindings(ci, sealed, selected.bindings), ...runtimeFindings(doc, ci, sealed), ...driftFindings(doc, ci, references)];
|
|
105
94
|
}
|
|
@@ -22,7 +22,9 @@ import { fileURLToPath } from 'node:url';
|
|
|
22
22
|
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
23
23
|
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
24
24
|
import { envelopeOf } from '../secret-document.mjs';
|
|
25
|
-
import { SSH_GROUP, sshTransportMembers } from '../secret-standard.mjs';
|
|
25
|
+
import { SSH_GROUP, sshTransportMembers, declaredServiceKeys } from '../secret-standard.mjs';
|
|
26
|
+
import { repositoryName, identityQueryEnvironment } from '../repo-identity.mjs';
|
|
27
|
+
import { serviceCredentialNames } from '../ci-declaration.mjs';
|
|
26
28
|
import { memberOfName } from '../../lib/sealed-member.mjs';
|
|
27
29
|
import { found, readText } from './read.mjs';
|
|
28
30
|
export const ENV_REQUIRED_UNSET = 'ENV_REQUIRED_UNSET';
|
|
@@ -342,12 +344,12 @@ function addRequiredFindings(reads, sealed, declared, findings) {
|
|
|
342
344
|
}
|
|
343
345
|
}
|
|
344
346
|
}
|
|
345
|
-
function sealedMemberFinding(key, member, reads, declared) {
|
|
347
|
+
function sealedMemberFinding(key, member, reads, declared, serviceConsumer) {
|
|
346
348
|
// The specific public-custody code wins over the generic unread one for a NEXT_PUBLIC_* key.
|
|
347
349
|
if (key.startsWith('NEXT_PUBLIC_')) {
|
|
348
350
|
return found(ENV_PUBLIC_SEALED, member, `${member} seals the public key ${key}; NEXT_PUBLIC_* values are public configuration and never enter custody`);
|
|
349
351
|
}
|
|
350
|
-
if (!reads.has(key) && !declared.has(key)) {
|
|
352
|
+
if (!reads.has(key) && !declared.has(key) && !serviceConsumer(member, key)) {
|
|
351
353
|
return found(ENV_SEALED_UNREAD, member, `${member} seals ${key}, which no EnvSource reader and no declared consumer uses; unseal it or declare who consumes it`);
|
|
352
354
|
}
|
|
353
355
|
return null;
|
|
@@ -362,10 +364,10 @@ function transportMembers(repoRoot) {
|
|
|
362
364
|
catch { /* a malformed stacks yaml is the shape rule's finding, not this one's */ }
|
|
363
365
|
return (environment) => sshTransportMembers(stacks.environments?.[environment]);
|
|
364
366
|
}
|
|
365
|
-
function addSealedFindings(sealed, reads, declared, transport, findings) {
|
|
367
|
+
function addSealedFindings(sealed, reads, declared, transport, findings, serviceConsumer) {
|
|
366
368
|
for (const [key, members] of sealed) {
|
|
367
369
|
for (const member of members) {
|
|
368
|
-
const finding = sealedMemberFinding(key, member, reads, declared);
|
|
370
|
+
const finding = sealedMemberFinding(key, member, reads, declared, serviceConsumer);
|
|
369
371
|
if (finding !== null)
|
|
370
372
|
findings.push(finding);
|
|
371
373
|
}
|
|
@@ -410,8 +412,15 @@ export function envParityFindings({ repoRoot, all = [], isRoot }) {
|
|
|
410
412
|
const sealed = sealedKeys(repoRoot, all);
|
|
411
413
|
const declared = declaredKeys(repoRoot, all);
|
|
412
414
|
const findings = [];
|
|
415
|
+
let stacks = {};
|
|
416
|
+
try {
|
|
417
|
+
stacks = parseYaml(readText(repoRoot, '.starcistacks/application-stacks.yaml') ?? '') ?? {};
|
|
418
|
+
}
|
|
419
|
+
catch { /* the shape owner reports invalid YAML */ }
|
|
420
|
+
const repository = repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(stacks)) });
|
|
421
|
+
const serviceConsumer = (file, key) => declaredServiceKeys(stacks, repository, file.split('/')[1], memberOfName(file.split('/').pop())).includes(key);
|
|
413
422
|
addRequiredFindings(reads, sealed, declared, findings);
|
|
414
|
-
addSealedFindings(sealed, reads, declared, transportMembers(repoRoot), findings);
|
|
423
|
+
addSealedFindings(sealed, reads, declared, transportMembers(repoRoot), findings, serviceConsumer);
|
|
415
424
|
addExampleFindings(repoRoot, all, reads, findings);
|
|
416
425
|
return findings;
|
|
417
426
|
}
|
|
@@ -46,6 +46,7 @@ export interface MoveOptions {
|
|
|
46
46
|
}
|
|
47
47
|
/** The provider a key belongs to: its first word (`SEPAY_IPN_SECRET_KEY` is `sepay`, `OPENROUTER_API_KEY` is `openrouter`). */
|
|
48
48
|
export declare const providerOf: (key: string) => string;
|
|
49
|
+
export declare function declaredServiceKeys(document: StacksDocument, repository: string, environment: string, member: string): string[];
|
|
49
50
|
/** The keys of the one sealed SSH member `deploy.remote.sealed` names (stack/remote.mjs): the deploy transport reads exactly these. */
|
|
50
51
|
export declare const SSH_GROUP: readonly string[];
|
|
51
52
|
/**
|
|
@@ -13,12 +13,26 @@ import { configuredRecipients } from "./secret-init.mjs";
|
|
|
13
13
|
import { envelopeOf, validateMap } from "./secret-document.mjs";
|
|
14
14
|
import { deleteSealed, fileOf, sealedFiles, writeSecret } from "./secret-store.mjs";
|
|
15
15
|
import { SecretError } from "./secret-errors.mjs";
|
|
16
|
+
import { serviceMemberPath, serviceCredentialNames } from "./ci-declaration.mjs";
|
|
17
|
+
import { repositoryName, identityQueryEnvironment } from "./repo-identity.mjs";
|
|
18
|
+
import { list } from "../lib/list.mjs";
|
|
16
19
|
/** The provider a key belongs to: its first word (`SEPAY_IPN_SECRET_KEY` is `sepay`, `OPENROUTER_API_KEY` is `openrouter`). */
|
|
17
20
|
export const providerOf = (key) => key.split("_")[0].toLowerCase();
|
|
18
21
|
const sameSet = (left, right) => left.length === right.length && left.every((item) => right.includes(item));
|
|
19
22
|
const keyList = (keys) => keys.join(", ");
|
|
20
23
|
/** The member a `secrets:` entry names: `member`, else the slug of `where`, else its `name`. */
|
|
21
24
|
const memberOfEntry = (entry) => entry?.member ?? /^secrets\/([a-z0-9-]+)/u.exec(entry?.where ?? "")?.[1] ?? entry?.name;
|
|
25
|
+
/** The declared connection/credential keys of this project's services, including separately kept operator login custody. */
|
|
26
|
+
function declaredServiceReferences(document, repository) {
|
|
27
|
+
const services = document.services;
|
|
28
|
+
return Object.values(services ?? {}).flatMap((service) => list(service?.credentials).flatMap((credential) => {
|
|
29
|
+
const selected = serviceMemberPath(credential?.custody?.path);
|
|
30
|
+
return credential?.custody?.repository === repository && selected && typeof credential.key === "string" ? [{ ...selected, key: credential.key }] : [];
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
export function declaredServiceKeys(document, repository, environment, member) {
|
|
34
|
+
return declaredServiceReferences(document, repository).filter((reference) => reference.environment === environment && reference.member === member).map((reference) => reference.key);
|
|
35
|
+
}
|
|
22
36
|
/** The keys an environment's `secrets:` declares for `member`: an entry naming `key` with that `member`, or a whole-member entry. */
|
|
23
37
|
function declaredKeys(spec, member, keys) {
|
|
24
38
|
const entries = Array.isArray(spec?.secrets) ? spec.secrets : [];
|
|
@@ -80,6 +94,11 @@ export function auditSecrets(repoRoot, environment = null) {
|
|
|
80
94
|
.map((entry) => entry.name)
|
|
81
95
|
.sort();
|
|
82
96
|
const findings = [];
|
|
97
|
+
const repository = repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(stacks)) });
|
|
98
|
+
for (const reference of declaredServiceReferences(stacks, repository).filter((item) => !environment || item.environment === environment)) {
|
|
99
|
+
if (!Object.hasOwn(stacks.environments ?? {}, reference.environment) || !fs.existsSync(path.join(repoRoot, reference.path)))
|
|
100
|
+
findings.push({ code: "MISSING", env: reference.environment, member: reference.member, detail: "service credential references a missing member or undeclared environment" });
|
|
101
|
+
}
|
|
83
102
|
for (const env of environments) {
|
|
84
103
|
const directory = path.join(root, env, "secrets");
|
|
85
104
|
const spec = stacks.environments?.[env];
|
|
@@ -92,16 +111,17 @@ export function auditSecrets(repoRoot, environment = null) {
|
|
|
92
111
|
const providers = [...new Set(envelope.keys.map(providerOf))];
|
|
93
112
|
if (isLegacyName(name))
|
|
94
113
|
add("LEGACY_MEMBER", `is a dotenv .enc member; move it: starci app secret migrate --env ${env} ${member} [--to <provider>] --execute`);
|
|
95
|
-
|
|
114
|
+
const serviceKeys = declaredServiceKeys(stacks, repository, env, member);
|
|
115
|
+
if (providers.length > 1 && !declaredGroup(spec, member, envelope.keys) && !envelope.keys.every((key) => serviceKeys.includes(key)))
|
|
96
116
|
add("MIXED_PROVIDERS", `holds keys of ${providers.length} providers by their first word (${providers.join(", ")}) that the stack does not group in it; split it (starci app secret split --env ${env} ${member} [--group KEY=member] --execute) or declare its keys in environments.${env}.secrets (an entry naming ${member} with keys: [...])`);
|
|
97
117
|
const wanted = recipientsFor(repoRoot, path.join(directory, `${member}${MEMBER_SUFFIX}`));
|
|
98
118
|
if (!isLegacyName(name) && wanted.length && !sameSet(wanted, envelope.recipients))
|
|
99
119
|
add("RECIPIENT_DRIFT", `is sealed to ${keyList(envelope.recipients)}, its environment's rule names ${keyList(wanted)}; seal it again: starci app secret migrate --env ${env} ${member} --execute`);
|
|
100
120
|
if (spec) {
|
|
101
|
-
const declared = declaredKeys(spec, member, envelope.keys);
|
|
121
|
+
const declared = [...declaredKeys(spec, member, envelope.keys), ...serviceKeys];
|
|
102
122
|
const missing = envelope.keys.filter((key) => !declared.includes(key));
|
|
103
123
|
if (missing.length)
|
|
104
|
-
add("UNDECLARED_KEY", `holds ${keyList(missing)}, not declared
|
|
124
|
+
add("UNDECLARED_KEY", `holds ${keyList(missing)}, not declared as runtime secrets or exact service credential references in application-stacks.yaml`);
|
|
105
125
|
}
|
|
106
126
|
if (tracked && !tracked.has(name))
|
|
107
127
|
add("UNTRACKED", "is not committed; commit it (it is ciphertext)");
|
|
@@ -155,7 +175,8 @@ export function declareKeys(repoRoot, environment, member, keys) {
|
|
|
155
175
|
const spec = document.environments?.[environment];
|
|
156
176
|
if (!spec)
|
|
157
177
|
return { declared: [], byHand: [] };
|
|
158
|
-
const
|
|
178
|
+
const serviceKeys = declaredServiceKeys(document, repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(document)) }), environment, member);
|
|
179
|
+
const missing = keys.filter((key) => !declaredKeys(spec, member, keys).includes(key) && !serviceKeys.includes(key));
|
|
159
180
|
if (!missing.length)
|
|
160
181
|
return { declared: [], byHand: [] };
|
|
161
182
|
// A swarm environment's secret is a versioned external secret its stack file names (runtimeName, deploy grants): its
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { type SecretSops } from "./secret-sops.mjs";
|
|
2
|
+
import type { ServiceBinding } from "./ci-declaration.mjs";
|
|
2
3
|
/** The directory of the sealed secrets of the app at `repoRoot` for `env`; the one env that has secrets when none is named. */
|
|
3
4
|
export declare function secretsDirectory(repoRoot: string, env?: string | null): string;
|
|
4
5
|
export declare const fileOf: (directory: string, slug: string | undefined) => string;
|
|
6
|
+
/** Judge a selected service member and cleartext key names without decrypting; its real path stays in this product. */
|
|
7
|
+
export declare function serviceCredentialFile(repoRoot: string, binding: ServiceBinding): string;
|
|
8
|
+
/** Native service loader: one exact key, decrypted in memory through the common identity/SOPS owner, never an env fallback. */
|
|
9
|
+
export declare function serviceCredentialValue(repoRoot: string, binding: ServiceBinding, sops: Pick<SecretSops, "decrypt">): string;
|
|
5
10
|
/** Replace one existing sealed member with an already validated envelope: same claim and atomic publish as set. */
|
|
6
11
|
export declare const replaceSealed: (file: string, sealed: string) => void;
|
|
7
12
|
/** Delete one sealed member under the writer claim. */
|
|
@@ -41,6 +41,37 @@ export const fileOf = (directory, slug) => {
|
|
|
41
41
|
throw new SecretError(error.message);
|
|
42
42
|
}
|
|
43
43
|
};
|
|
44
|
+
/** Judge a selected service member and cleartext key names without decrypting; its real path stays in this product. */
|
|
45
|
+
export function serviceCredentialFile(repoRoot, binding) {
|
|
46
|
+
const file = fileOf(secretsDirectory(repoRoot, binding.environment), binding.member);
|
|
47
|
+
const declared = path.resolve(repoRoot, binding.path);
|
|
48
|
+
try {
|
|
49
|
+
if (file !== declared || !fs.statSync(file).isFile() || fs.realpathSync(file) !== path.join(fs.realpathSync(repoRoot), binding.path))
|
|
50
|
+
throw new SecretError("service custody must name one regular sealed member inside this product");
|
|
51
|
+
if (!envelopeOf(fs.readFileSync(file, "utf8")).keys.includes(binding.key))
|
|
52
|
+
throw new SecretError("service custody does not hold the declared key");
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
throw new SecretError("service custody is missing, ambiguous or outside this product");
|
|
56
|
+
}
|
|
57
|
+
return file;
|
|
58
|
+
}
|
|
59
|
+
/** Native service loader: one exact key, decrypted in memory through the common identity/SOPS owner, never an env fallback. */
|
|
60
|
+
export function serviceCredentialValue(repoRoot, binding, sops) {
|
|
61
|
+
const file = serviceCredentialFile(repoRoot, binding);
|
|
62
|
+
try {
|
|
63
|
+
const envelope = envelopeOf(fs.readFileSync(file, "utf8"));
|
|
64
|
+
if (!envelope.keys.includes(binding.key))
|
|
65
|
+
throw new SecretError("service credential key is absent from the selected member");
|
|
66
|
+
const value = sops.decrypt(file, envelope.format)[binding.key];
|
|
67
|
+
if (typeof value !== "string" || !value.length)
|
|
68
|
+
throw new SecretError("service credential key is empty or absent");
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new SecretError("service credential custody could not be opened; check its member, key and owner identity");
|
|
73
|
+
}
|
|
74
|
+
}
|
|
44
75
|
/**
|
|
45
76
|
* Build and validate a replacement envelope without publishing it. An existing member is decrypted only when it holds a key
|
|
46
77
|
* `values` does not replace; a member whose every key is replaced (the one-key member a `set` re-seals) needs no age identity.
|
package/src/main.mjs
CHANGED
|
@@ -64,7 +64,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
64
64
|
// starci app secret materialize --env <env> | list --env <env> | verify --env <env> | recipients --env <env> [--add|--remove <age1...|owner>] [--execute]
|
|
65
65
|
// dotenv custody in .starcistacks, public age rule initialization, private KEY files outside Git;
|
|
66
66
|
// values reach stdout only when get explicitly receives --reveal.
|
|
67
|
-
// starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] [--execute]
|
|
67
|
+
// starci app ci secret set <NAME> [--from-custody] [--env E | --dependabot] [--repo owner/name] [--execute] stdin or the explicit service custody binding; dry until --execute.
|
|
68
68
|
// starci app ci var set <NAME> [--env E] [--value v] [--execute] | var sync [--execute] | verify [--json] | local [--execute]
|
|
69
69
|
// declared GitHub CI custody; verify compares names, scopes and public values; local prints the CI plan, --execute runs it.
|
|
70
70
|
// starci app db new <name> | baseline | restamp --map <restamp.yaml> | migrate | post-cutover | dump | drift [--db-url-env <NAME>]
|
package/sync/ci-commands.mjs
CHANGED
|
@@ -35,7 +35,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
|
35
35
|
if (!sonar)
|
|
36
36
|
return '';
|
|
37
37
|
const condition = SONAR_CONDITION;
|
|
38
|
-
const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
|
|
38
|
+
const host = sonar.hostSecret ? `\${{ secrets.${sonar.hostSecret} }}` : sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
|
|
39
39
|
const header = [
|
|
40
40
|
' sonar:',
|
|
41
41
|
' needs: [plan, lint, coverage]',
|
|
@@ -52,6 +52,7 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
|
|
|
52
52
|
` if: ${condition}`,
|
|
53
53
|
' run: |',
|
|
54
54
|
` if [ -z "$SONAR_TOKEN" ]; then echo "::error::SONAR_TOKEN is not set: Sonar is required, so a missing token fails this job (secret ${sonar.token})"; exit 1; fi`,
|
|
55
|
+
' if [ -z "$SONAR_HOST_URL" ]; then echo "::error::SONAR_HOST_URL is not set: the declared analysis endpoint is required"; exit 1; fi',
|
|
55
56
|
` - uses: ${DOWNLOAD_ARTIFACT}`,
|
|
56
57
|
` if: ${condition}`,
|
|
57
58
|
' with:',
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
4
|
-
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
4
|
+
import { qualityServices, serviceCredentialNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
5
|
+
import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
|
|
5
6
|
import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
|
|
6
7
|
import { badgeTokenNotices, renderReadmeBadges } from './readme-badges.mjs';
|
|
7
8
|
import { DECLARATION } from './sonar-key.mjs';
|
|
@@ -19,7 +20,7 @@ export function hasDeclaredServices(root, { parseYaml = bundledParseYaml } = {})
|
|
|
19
20
|
export function readDeclaredQuality(root, { parseYaml = bundledParseYaml } = {}) {
|
|
20
21
|
try {
|
|
21
22
|
const doc = parseYaml(fs.readFileSync(path.join(root, DECLARATION), 'utf8'));
|
|
22
|
-
return qualityServices(doc, { example: isRuntimeExampleApp(root) });
|
|
23
|
+
return qualityServices(doc, { example: isRuntimeExampleApp(root), repository: repositoryName(root, { env: identityQueryEnvironment(process.env, serviceCredentialNames(doc)) }) });
|
|
23
24
|
}
|
|
24
25
|
catch {
|
|
25
26
|
return qualityServices({});
|
package/sync/readme-badges.mjs
CHANGED
|
@@ -17,7 +17,7 @@ export function renderReadmeBadges(doc, { repository, example = false } = {}) {
|
|
|
17
17
|
const branch = encodeURIComponent(github.defaultBranch ?? 'main');
|
|
18
18
|
const workflow = encodeURIComponent(github.workflow ?? 'ci.yml');
|
|
19
19
|
const base = `https://github.com/${repo}`;
|
|
20
|
-
const quality = qualityServices(doc, { example });
|
|
20
|
+
const quality = qualityServices(doc, { example, repository: repo.split('/')[1] });
|
|
21
21
|
const lines = [badge('CI', `${base}/actions/workflows/${workflow}/badge.svg?branch=${branch}`, `${base}/actions/workflows/${workflow}`)];
|
|
22
22
|
if (quality.codecov.enabled) {
|
|
23
23
|
const params = [];
|
package/sync/sonar-key.d.mts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { repositoryName } from '../runtime/scripts/hfs/repo-identity.mjs';
|
|
2
|
+
export { repositoryName };
|
|
1
3
|
type Declaration = any;
|
|
2
4
|
type ParseYaml = (text: string) => Declaration;
|
|
3
5
|
/** The stack declaration, app-relative: .starcistacks sits at the app root, beside be/, fe/ and .starciwork. */
|
|
4
6
|
export declare const DECLARATION: string;
|
|
5
|
-
/** The repository name the declaration lists its projects under: the app package.json name, else the folder name. */
|
|
6
|
-
export declare function repositoryName(root: string): string;
|
|
7
7
|
/** The keys services.sonar declares for `repository` in a parsed declaration (empty when it declares none). */
|
|
8
8
|
export declare function declaredSonarKeys(declaration: Declaration, repository: string): string[];
|
|
9
9
|
/**
|
|
@@ -28,4 +28,3 @@ export declare function readDeclaredSonarGate(root: string, { parseYaml }?: {
|
|
|
28
28
|
} | null;
|
|
29
29
|
/** The quality-gate name for an app edition from the one sonar-gate document. */
|
|
30
30
|
export declare function sonarGateName(document: Declaration, edition?: string): string | null;
|
|
31
|
-
export {};
|
package/sync/sonar-key.mjs
CHANGED
|
@@ -4,20 +4,11 @@
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
|
|
7
|
+
import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
|
|
8
|
+
import { serviceCredentialNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
9
|
+
export { repositoryName };
|
|
7
10
|
/** The stack declaration, app-relative: .starcistacks sits at the app root, beside be/, fe/ and .starciwork. */
|
|
8
11
|
export const DECLARATION = path.join('.starcistacks', 'application-stacks.yaml');
|
|
9
|
-
/** The repository name the declaration lists its projects under: the app package.json name, else the folder name. */
|
|
10
|
-
export function repositoryName(root) {
|
|
11
|
-
try {
|
|
12
|
-
const name = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name;
|
|
13
|
-
if (typeof name === 'string' && name)
|
|
14
|
-
return name;
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
// no readable package.json: the folder name stands in
|
|
18
|
-
}
|
|
19
|
-
return path.basename(path.resolve(root));
|
|
20
|
-
}
|
|
21
12
|
/** The keys services.sonar declares for `repository` in a parsed declaration (empty when it declares none). */
|
|
22
13
|
export function declaredSonarKeys(declaration, repository) {
|
|
23
14
|
const projects = declaration?.services?.sonar?.projects;
|
|
@@ -35,9 +26,11 @@ export async function readDeclaredSonarKey(root, { parseYaml, fail }) {
|
|
|
35
26
|
if (!fs.existsSync(file))
|
|
36
27
|
return null;
|
|
37
28
|
const parse = parseYaml ?? bundledParseYaml;
|
|
38
|
-
const
|
|
29
|
+
const doc = parse(fs.readFileSync(file, 'utf8'));
|
|
30
|
+
const repository = repositoryName(root, { env: identityQueryEnvironment(process.env, serviceCredentialNames(doc)) });
|
|
31
|
+
const keys = declaredSonarKeys(doc, repository);
|
|
39
32
|
if (keys.length > 1)
|
|
40
|
-
fail(`${DECLARATION.split(path.sep).join('/')} declares ${keys.length} Sonar projects for ${
|
|
33
|
+
fail(`${DECLARATION.split(path.sep).join('/')} declares ${keys.length} Sonar projects for ${repository} (${keys.join(', ')}); one app has one key`);
|
|
41
34
|
return keys[0] ?? null;
|
|
42
35
|
}
|
|
43
36
|
/**
|