@starci/hfs 5.0.5 → 5.0.6

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.
Files changed (49) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +22 -2
  3. package/cache/session.mjs +1 -1
  4. package/cache/test-family.d.mts +1 -1
  5. package/cache/test-family.mjs +15 -7
  6. package/ci/cli.d.mts +7 -1
  7. package/ci/cli.mjs +37 -6
  8. package/ci/github.d.mts +1 -0
  9. package/ci/secret.d.mts +10 -1
  10. package/ci/secret.mjs +26 -6
  11. package/ci/verify.d.mts +9 -1
  12. package/ci/verify.mjs +5 -3
  13. package/package.json +1 -1
  14. package/release/candidate-ci.mjs +1 -1
  15. package/release/gate.d.mts +1 -0
  16. package/release/gate.mjs +76 -18
  17. package/release/index.d.mts +1 -1
  18. package/release/index.mjs +7 -3
  19. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  20. package/runtime/knowledge/hfs/rules.yaml +2 -2
  21. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  22. package/runtime/modules/schemas/application-stacks.schema.yaml +10 -7
  23. package/runtime/scripts/gates/starcistacks-ci.mjs +3 -1
  24. package/runtime/scripts/gates/starcistacks-form.d.mts +1 -0
  25. package/runtime/scripts/gates/starcistacks.d.mts +1 -0
  26. package/runtime/scripts/gates/starcistacks.mjs +22 -10
  27. package/runtime/scripts/hfs/architecture/contract-bounds.d.mts +3 -0
  28. package/runtime/scripts/hfs/architecture/contract-bounds.mjs +72 -0
  29. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  30. package/runtime/scripts/hfs/architecture/tiers.d.mts +2 -1
  31. package/runtime/scripts/hfs/architecture/tiers.mjs +45 -4
  32. package/runtime/scripts/hfs/ci-declaration.d.mts +47 -1
  33. package/runtime/scripts/hfs/ci-declaration.mjs +115 -3
  34. package/runtime/scripts/hfs/repo-identity.d.mts +5 -1
  35. package/runtime/scripts/hfs/repo-identity.mjs +15 -10
  36. package/runtime/scripts/hfs/rules/ci-custody.mjs +21 -32
  37. package/runtime/scripts/hfs/rules/env-parity.mjs +15 -6
  38. package/runtime/scripts/hfs/secret-standard.d.mts +1 -0
  39. package/runtime/scripts/hfs/secret-standard.mjs +25 -4
  40. package/runtime/scripts/hfs/secret-store.d.mts +5 -0
  41. package/runtime/scripts/hfs/secret-store.mjs +31 -0
  42. package/src/main.mjs +1 -1
  43. package/sync/ci-commands.mjs +2 -1
  44. package/sync/quality-services.mjs +3 -2
  45. package/sync/readme-badges.mjs +1 -1
  46. package/sync/sonar-key.d.mts +2 -3
  47. package/sync/sonar-key.mjs +7 -14
  48. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +7 -3
  49. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +7 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.6
4
+
5
+ - Changed: Support sealed per-project quality-service credentials and preserve valid composite cache entries and reused spec records.
6
+
7
+ - Fixed: Preserve generated-contract provenance in front-end tier and bounds checks.
8
+
3
9
  ## 5.0.5
4
10
 
5
11
  - Changed: Govern each app-check linter, compiler, test discovery and coverage, contract emitter and boot child through the CLI execution port with actual worker limits and complete output capture. Preserve cache identities, closure, flush behavior and coverage thresholds. Record source-proven native Page body layouts with their contextual constraints.
package/README.md CHANGED
@@ -26,6 +26,7 @@ starci app slot <path> [--cwd <dir>] [--json]
26
26
  starci app emit [--cwd <dir>]
27
27
  starci app db drift [--db-url-env <NAME>] [--json] [--cwd <dir>]
28
28
  starci app secret init | <set|get|unset|rename|remove|materialize|list|verify|recipients|migrate|split|audit|server-key> --env <env> ... [--cwd <dir>]
29
+ starci app ci secret set <NAME> [--from-custody] [--env <env>] [--repo <owner/name>] [--execute]
29
30
  ```
30
31
 
31
32
  `starci app add` writes every front-end unit, in either edition, already in the shape of the harness's `contexts/code/fe`
@@ -227,8 +228,9 @@ read until `migrate` moves it (`runtime/scripts/lib/sealed-member.mjs` names bot
227
228
  seals every named key in one sops write (`runtime/scripts/hfs/secret.mjs`): on a terminal it prompts `<KEY>: ` for each with
228
229
  masked input (one `*` per character; a paste, backspace, Enter, Ctrl+C to cancel with nothing written), redirected stdin gives
229
230
  one key its whole text or several one `KEY=value` line each, `--from-file KEY=path` reads a key byte for byte
230
- (`secret-prompt.mjs`), and `--generate <bytes>` makes a random value per key. It then declares the keys in the environment's
231
- `secrets:` of a compose environment (`declareKeys`, `secret-standard.mjs`) and names a swarm environment's to wire by hand.
231
+ (`secret-prompt.mjs`), and `--generate <bytes>` makes a random value per key. Keys declared by exact service credential
232
+ references stay with that consumer; other keys are declared in the environment's `secrets:` of a compose environment
233
+ (`declareKeys`, `secret-standard.mjs`), while a swarm environment's are named to wire by hand.
232
234
  A member is sealed to its environment's rule of the managed `.sops.yaml`; `recipients --env <env> [--add|--remove <age1...|owner>]`
233
235
  rewrites that rule (`environmentRecipients`, `secret-init.mjs`), and `init` takes the owner's identity as the app's recipient.
234
236
  Re-sealing a member whose every key the set replaces needs no age identity; adding a key to one does, and the identity is found
@@ -244,6 +246,24 @@ except `get --reveal`.
244
246
 
245
247
  ## Sonar
246
248
 
249
+ The product's `.starcistacks/application-stacks.yaml` holds its connection profile: provider, local/hosted mode, public
250
+ URL and `projects` entries. Each stored username, password, private URL or token has a separate `credentials[]` reference
251
+ to its actual sealed member and `key`; a private endpoint uses `host.fromCredential`. A project analysis reference has
252
+ `project: <projects[].key>`, independently of its reference `id`. Operator/admin login keys live in a separate authority
253
+ member and are never selected implicitly by analysis or CI. Change the declared connection profile when moving to a
254
+ hosted/cloud server; usernames, passwords and tokens remain sealed.
255
+
256
+ The CI execution copy is explicit: `services.sonar.ci.secrets: [{name: <CI_NAME>, credential: <analysis-id>}]`, with
257
+ `ci.token: <CI_NAME>` and the name/scope declared in the top-level `ci.secrets`. Declare the exact product credential
258
+ first, seal it with `starci app secret set --env <declared-env> <provider> <KEY>`, then run
259
+ `starci app ci secret set <CI_NAME> --from-custody` to inspect the plan and add `--execute` to stream its selected value
260
+ to the CI provider. A dry run decrypts no credential, reads no stdin and reaches no remote. Custody input uses terminal or
261
+ closed/null stdin; a competing pipe/file channel, even empty, is refused without reading it. A private endpoint has its
262
+ own explicit secret-copy link selected by `ci.hostVar`; public endpoint selectors read CI variables. The native local release opens
263
+ the selected product token through the common SOPS identity loader in memory and passes it only to the scanner child.
264
+ Missing, ambiguous, unopenable, wrong-project or runtime-granted custody is a failure, with no host/global fallback.
265
+ Analysis never creates a project, replaces its history or rotates a token. OIDC Codecov keeps no synthetic token member.
266
+
247
267
  One mechanism for both sides: every finding of the canon is imported into Sonar, and the quality gate
248
268
  fails while any is open. Nothing is configured per app; the pieces are managed files (`starci app sync`) and this package.
249
269
 
package/cache/session.mjs CHANGED
@@ -137,7 +137,7 @@ export function createDiskSession(options) {
137
137
  // A file edited while the run read it gets no record.
138
138
  const settled = new Map();
139
139
  for (const [folder, { records }] of replaced) {
140
- settled.set(folder, records.filter((record) => index.hashOf(record.path) === undefined || hashFile(path.join(root, record.path)) === record.hash));
140
+ settled.set(folder, records.filter((record) => index.hashOf(record.path) === undefined || hashFile(path.join(root, record.path)) === index.hashOf(record.path)));
141
141
  }
142
142
  if (trust.kind === 'base') {
143
143
  const committed = new Map();
@@ -68,7 +68,7 @@ export interface TestFamilyResult {
68
68
  readonly ok: boolean;
69
69
  readonly ran: readonly string[];
70
70
  readonly reused: readonly string[];
71
- /** Specs that failed or did not report: nothing of theirs is stored. */
71
+ /** Specs that failed, did not report, or whose inputs changed: nothing of theirs is stored. */
72
72
  readonly failed: readonly string[];
73
73
  /** The merged lcov, or undefined when no spec has a record. */
74
74
  readonly lcov: string | undefined;
@@ -83,9 +83,8 @@ export async function runTestFamily(options) {
83
83
  const reported = new Map(jest.readCoverage(scratch).map((file) => [file.spec, file]));
84
84
  for (const spec of toRun) {
85
85
  const file = reported.get(posix(path.relative(path.join(session.root, side), path.join(session.root, spec))));
86
- const key = specKey({ spec, closure: jest.closureOf(spec), configHash: jest.configHash, hashOf });
87
- // A spec that failed, did not report, or whose files changed while it ran stores nothing.
88
- if (file?.passed === true && key === keys.get(spec))
86
+ const key = keys.get(spec);
87
+ if (file?.passed === true && key !== undefined)
89
88
  fresh.push(recordOf(spec, key, file.sources, side));
90
89
  else
91
90
  failed.push(spec);
@@ -96,9 +95,18 @@ export async function runTestFamily(options) {
96
95
  fs.rmSync(scratch, { recursive: true, force: true });
97
96
  }
98
97
  }
99
- progress.tick(jest.specs.length, kept.length, toRun.length);
98
+ // Recheck every candidate against disk, including a reused spec whose inputs another running spec could change.
99
+ const records = [...kept, ...fresh].filter((record) => {
100
+ const key = specKey({ spec: record.path, closure: jest.closureOf(record.path), configHash: jest.configHash, hashOf: (file) => hashFile(path.join(session.root, file)) });
101
+ if (key === keys.get(record.path))
102
+ return true;
103
+ failed.push(record.path);
104
+ return false;
105
+ });
106
+ const settled = new Set(records.map((record) => record.path));
107
+ const reused = kept.filter((record) => settled.has(record.path));
108
+ progress.tick(jest.specs.length, reused.length, toRun.length);
100
109
  progress.end();
101
- const records = [...kept, ...fresh];
102
110
  session.replace(TEST_FOLDER, records);
103
111
  let lcov;
104
112
  let threshold;
@@ -117,13 +125,13 @@ export async function runTestFamily(options) {
117
125
  mode: decision.mode,
118
126
  reason: decision.reason,
119
127
  checked: toRun.length,
120
- reused: kept.length,
128
+ reused: reused.length,
121
129
  newFindings: failed.length + thresholdFindings,
122
130
  knownFindings: 0,
123
131
  fixedFindings: 0,
124
132
  };
125
133
  session.report(report);
126
- return { ok: failed.length === 0 && (threshold?.ok ?? records.length === 0), ran: toRun, reused: kept.map((record) => record.path), failed, lcov, threshold, report, output };
134
+ return { ok: failed.length === 0 && (threshold?.ok ?? records.length === 0), ran: toRun, reused: reused.map((record) => record.path), failed, lcov, threshold, report, output };
127
135
  }
128
136
  /** The digest of a project config with host paths turned into `<root>` and the keys that differ per host dropped. */
129
137
  export function projectConfigHash(config) {
package/ci/cli.d.mts CHANGED
@@ -1,9 +1,11 @@
1
+ import { type SecretSops } from '../runtime/scripts/hfs/secret-sops.mjs';
2
+ import type { AgeHost } from '../runtime/scripts/hfs/secret-init.mjs';
1
3
  import type { GhSpawn, StdinSource } from './github.mjs';
2
4
  /** The commands of `starci app ci` (besides `local`) with the options each takes; the parser refuses every other. */
3
5
  export declare const COMMANDS: Record<string, string[]>;
4
6
  export declare const BOOLEAN: Set<string>;
5
7
  /** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
6
- export declare function ciMain(argv: string[], { cwd, stdout, stderr, stdin, env, tools }?: {
8
+ export declare function ciMain(argv: string[], { cwd, stdout, stderr, stdin, env, tools, hostIdentity, sops, platform, inputRedirected }?: {
7
9
  cwd?: string | undefined;
8
10
  stdout?: ((text: string) => unknown) | undefined;
9
11
  stderr?: ((text: string) => unknown) | undefined;
@@ -12,4 +14,8 @@ export declare function ciMain(argv: string[], { cwd, stdout, stderr, stdin, env
12
14
  tools?: {
13
15
  spawn?: GhSpawn | undefined;
14
16
  } | undefined;
17
+ hostIdentity?: AgeHost | null | undefined;
18
+ sops?: Pick<SecretSops, 'decrypt'> | null | undefined;
19
+ platform?: NodeJS.Platform | undefined;
20
+ inputRedirected?: (() => boolean) | undefined;
15
21
  }): Promise<number>;
package/ci/cli.mjs CHANGED
@@ -1,19 +1,22 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseYaml } from '../runtime/engine/yaml.mjs';
4
- import { isValidCiDeclaration, readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
4
+ import { isValidCiDeclaration, readCiDeclaration, sealedKeyNames, serviceBindings, serviceCredentialNames, runtimeGranted } from '../runtime/scripts/hfs/ci-declaration.mjs';
5
+ import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
6
+ import { serviceCredentialFile, serviceCredentialValue } from '../runtime/scripts/hfs/secret-store.mjs';
7
+ import { defaultSops } from '../runtime/scripts/hfs/secret-sops.mjs';
5
8
  import { github, refuse } from './github.mjs';
6
9
  import { setSecret } from './secret.mjs';
7
10
  import { setVariable, syncVariables } from './vars.mjs';
8
11
  import { badgeVisibilityNotices, verifyCi } from './verify.mjs';
9
12
  /** The commands of `starci app ci` (besides `local`) with the options each takes; the parser refuses every other. */
10
13
  export const COMMANDS = {
11
- 'secret set': ['env', 'dependabot', 'repo', 'execute'],
14
+ 'secret set': ['env', 'dependabot', 'repo', 'execute', 'from-custody'],
12
15
  'var set': ['env', 'value', 'repo', 'execute'],
13
16
  'var sync': ['execute', 'repo'],
14
17
  verify: ['json', 'repo'],
15
18
  };
16
- export const BOOLEAN = new Set(['dependabot', 'execute', 'json']);
19
+ export const BOOLEAN = new Set(['dependabot', 'execute', 'json', 'from-custody']);
17
20
  /** Invalid options never echo argv, because an extra argument may itself contain a secret. */
18
21
  function parse(argv) {
19
22
  const command = argv[0] === 'verify' ? 'verify' : argv.slice(0, 2).join(' ');
@@ -52,7 +55,7 @@ function parse(argv) {
52
55
  return { command, options: options, name: positional[0] };
53
56
  }
54
57
  /** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
55
- export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
58
+ export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {}, hostIdentity, sops, platform = process.platform, inputRedirected } = {}) {
56
59
  // `ci local` runs the rendered CI on this machine (local.mjs): no manifest custody and no GitHub.
57
60
  if (argv[0] === 'local')
58
61
  return (await import('./local.mjs')).ciLocalMain(argv.slice(1), { cwd, stdout, stderr, env });
@@ -69,8 +72,36 @@ export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => proces
69
72
  }
70
73
  if (!isValidCiDeclaration(declaration))
71
74
  throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
72
- const gh = github({ cwd, env, spawn: tools.spawn });
73
- const context = { ...parsed, declaration, sealed, gh, stdin, stdout };
75
+ const childEnv = identityQueryEnvironment(env, serviceCredentialNames(doc));
76
+ const repository = repositoryName(cwd, { env: childEnv }), selected = serviceBindings(doc, repository);
77
+ if (selected.problems.length)
78
+ throw refuse('CI_CUSTODY_INVALID', 'correct the explicit project service credential bindings before using CI');
79
+ const runtime = sealed.filter((entry) => runtimeGranted(doc, entry.env, entry.member, entry.key));
80
+ if (parsed.command.endsWith(' set') && runtime.some((entry) => entry.key === parsed.name))
81
+ throw refuse('APP_SECRET_IN_CI', 'the selected key belongs to app runtime authority');
82
+ if (parsed.options['from-custody']) {
83
+ const binding = selected.bindings.find((item) => item.name === parsed.name);
84
+ if (!binding || parsed.options.dependabot)
85
+ throw refuse('CI_CUSTODY_INVALID', 'one project service binding is required');
86
+ try {
87
+ serviceCredentialFile(cwd, binding);
88
+ }
89
+ catch {
90
+ throw refuse('CI_CUSTODY_INVALID', 'the selected project member is missing, ambiguous or outside this product');
91
+ }
92
+ if (parsed.options.repo && (parsed.options.repo.split('/')[1] !== repository || (doc.ci?.github?.repository && parsed.options.repo !== doc.ci.github.repository)))
93
+ throw refuse('CI_CUSTODY_INVALID', 'the destination repository differs from this project');
94
+ }
95
+ const gh = github({ cwd, env: childEnv, spawn: tools.spawn });
96
+ const custody = (binding) => {
97
+ try {
98
+ return serviceCredentialValue(cwd, binding, sops ?? defaultSops(env, cwd, platform, undefined, hostIdentity));
99
+ }
100
+ catch {
101
+ throw refuse('CI_CUSTODY_UNAVAILABLE', 'the selected service member could not be opened with the owner identity');
102
+ }
103
+ };
104
+ const context = { ...parsed, declaration, sealed, runtime, gh, stdin, stdout, bindings: selected.bindings, custody, inputRedirected };
74
105
  if (parsed.command === 'secret set')
75
106
  return await setSecret(context);
76
107
  if (parsed.command === 'var set')
package/ci/github.d.mts CHANGED
@@ -11,6 +11,7 @@ export interface CiOptions extends CiScope {
11
11
  value?: string | undefined;
12
12
  execute?: boolean | undefined;
13
13
  json?: boolean | undefined;
14
+ 'from-custody'?: boolean | undefined;
14
15
  }
15
16
  /** One declared CI secret or variable of a declaration without problems (runtime/scripts/hfs/ci-declaration.mjs readCiDeclaration). */
16
17
  export type CiEntry = ValidCiEntry;
package/ci/secret.d.mts CHANGED
@@ -1,10 +1,16 @@
1
1
  import type { CiDeclaration, CiEntry, CiOptions, Gh, StdinSource } from './github.mjs';
2
+ import { type ServiceBinding } from '../runtime/scripts/hfs/ci-declaration.mjs';
3
+ /** An input pipe or file is an input source even before its bytes arrive; fstat never consumes its value. */
4
+ export declare function redirectedSecretInput(): boolean;
2
5
  /** A declared name must belong to the requested store before a secret body is read. */
3
6
  export declare function declaredEntry(entries: readonly CiEntry[], name: string, options: CiOptions): CiEntry;
4
7
  /** Secret input is read once, stripped of one trailing newline, and passed only to the child stdin; without options.execute nothing is read or written. */
5
- export declare function setSecret({ declaration, sealed, gh, options, name, stdin, stdout }: {
8
+ export declare function setSecret({ declaration, sealed, gh, options, name, stdin, stdout, bindings, custody, inputRedirected }: {
6
9
  declaration: CiDeclaration;
7
10
  sealed: readonly {
11
+ env?: string;
12
+ member?: string;
13
+ file?: string;
8
14
  key: string;
9
15
  }[];
10
16
  gh: Gh;
@@ -12,4 +18,7 @@ export declare function setSecret({ declaration, sealed, gh, options, name, stdi
12
18
  name: string;
13
19
  stdin: StdinSource;
14
20
  stdout: (text: string) => unknown;
21
+ bindings?: ServiceBinding[];
22
+ custody?: ((binding: ServiceBinding) => string) | undefined;
23
+ inputRedirected?: (() => boolean) | undefined;
15
24
  }): Promise<number>;
package/ci/secret.mjs CHANGED
@@ -1,5 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
3
+ import { boundSealedKey } from '../runtime/scripts/hfs/ci-declaration.mjs';
4
+ /** An input pipe or file is an input source even before its bytes arrive; fstat never consumes its value. */
5
+ export function redirectedSecretInput() {
6
+ try {
7
+ const input = fs.fstatSync(0);
8
+ return !input.isCharacterDevice();
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
3
14
  /** A declared name must belong to the requested store before a secret body is read. */
4
15
  export function declaredEntry(entries, name, options) {
5
16
  const entry = entries.find((item) => item.name === name);
@@ -11,24 +22,33 @@ export function declaredEntry(entries, name, options) {
11
22
  return entry;
12
23
  }
13
24
  /** Secret input is read once, stripped of one trailing newline, and passed only to the child stdin; without options.execute nothing is read or written. */
14
- export async function setSecret({ declaration, sealed, gh, options, name, stdin, stdout }) {
15
- if (sealed.some((item) => item.key === name))
16
- throw refuse('CI_SECRET_SEALED', 'the name belongs to a sealed app secret');
25
+ export async function setSecret({ declaration, sealed, gh, options, name, stdin, stdout, bindings = [], custody, inputRedirected = redirectedSecretInput }) {
17
26
  declaredEntry(options.dependabot ? declaration.dependabotSecrets : declaration.secrets, name, options);
27
+ const binding = options.dependabot ? undefined : bindings.find((item) => item.name === name);
28
+ if (sealed.some((item) => (item.key === name || (binding && item.key === binding.key)) && !(binding && boundSealedKey(binding, item))))
29
+ throw refuse('CI_SECRET_SEALED', 'the name or selected key belongs to unrelated sealed app custody');
30
+ if (binding && options['from-custody'] !== true)
31
+ throw refuse('CI_CUSTODY_REQUIRED', 'this service secret is an execution copy; use --from-custody');
32
+ if (options['from-custody'] && (!binding || !custody))
33
+ throw refuse('CI_CUSTODY_INVALID', 'one explicit project service credential binding is required');
34
+ if (options['from-custody'] && (stdin !== undefined || inputRedirected()))
35
+ throw refuse('CI_VALUE_AMBIGUOUS', 'choose custody or stdin, never both');
18
36
  // Dry until --execute: the name is judged, but no value is read and GitHub is not reached; the line never carries a value.
19
37
  if (options.execute !== true) {
20
- stdout(`dry run: would set secret ${name} (${scopeName(options)}) from the value on stdin; add --execute\n`);
38
+ stdout(`dry run: would set secret ${name} (${scopeName(options)}) from ${binding ? 'its declared service custody' : 'the value on stdin'}; add --execute\n`);
21
39
  return 0;
22
40
  }
23
41
  let value;
24
- if (stdin === undefined) {
42
+ if (binding && custody)
43
+ value = custody(binding);
44
+ else if (stdin === undefined) {
25
45
  if (process.stdin.isTTY)
26
46
  throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
27
47
  value = fs.readFileSync(0, 'utf8');
28
48
  }
29
49
  else
30
50
  value = typeof stdin === 'function' ? await stdin() : stdin;
31
- value = String(value ?? '').replace(/\r?\n$/, '');
51
+ value = binding ? value : String(value ?? '').replace(/\r?\n$/, '');
32
52
  if (!value.length)
33
53
  throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
34
54
  const repo = gh.repository(options.repo);
package/ci/verify.d.mts CHANGED
@@ -1,14 +1,22 @@
1
1
  import type { CiDeclaration, CiOptions, Gh } from './github.mjs';
2
+ import { type ServiceBinding } from '../runtime/scripts/hfs/ci-declaration.mjs';
2
3
  /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
3
- export declare function verifyCi({ declaration, sealed, gh, options, stdout, notices }: {
4
+ export declare function verifyCi({ declaration, sealed, gh, options, stdout, notices, bindings, runtime }: {
4
5
  declaration: CiDeclaration;
5
6
  sealed: readonly {
7
+ env?: string;
8
+ member?: string;
9
+ file?: string;
6
10
  key: string;
7
11
  }[];
8
12
  gh: Gh;
9
13
  options: CiOptions;
10
14
  stdout: (text: string) => unknown;
11
15
  notices?: string[];
16
+ bindings?: ServiceBinding[];
17
+ runtime?: readonly {
18
+ key: string;
19
+ }[];
12
20
  }): number;
13
21
  /** Manifest visibility wins; optional GitHub visibility discovery never changes verification findings or errors. */
14
22
  export declare function badgeVisibilityNotices({ doc, gh, repo }: {
package/ci/verify.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { badgeTokenNotices } from '../sync/readme-badges.mjs';
2
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
3
+ import { boundSealedKey } from '../runtime/scripts/hfs/ci-declaration.mjs';
3
4
  /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
4
- export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [] }) {
5
+ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [], bindings = [], runtime = [] }) {
5
6
  const repo = gh.repository(options.repo), stores = [];
6
7
  const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
7
8
  for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
@@ -12,7 +13,7 @@ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [
12
13
  stores.push({ scope, kind, rows });
13
14
  }
14
15
  }
15
- const findings = [], sealedNames = new Set(sealed.map((item) => item.key));
16
+ const findings = [];
16
17
  const declarations = (store) => store.scope.dependabot ? declaration.dependabotSecrets : store.kind === 'secret' ? declaration.secrets : declaration.vars;
17
18
  const matches = (entry, scope) => (entry.scope === 'environment' ? entry.environment : undefined) === scope.env;
18
19
  const add = (code, name, scope) => { findings.push({ code, name, scope: scopeName(scope) }); };
@@ -26,7 +27,8 @@ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [
26
27
  add('CI_VAR_DRIFT', entry.name, store.scope);
27
28
  }
28
29
  for (const row of store.rows) {
29
- if (store.kind === 'secret' && sealedNames.has(row.name))
30
+ const binding = store.scope.dependabot ? undefined : bindings.find((item) => item.name === row.name);
31
+ if (runtime.some((item) => item.key === row.name) || (store.kind === 'secret' && sealed.some((item) => (item.key === row.name || (binding && item.key === binding.key)) && !(binding && boundSealedKey(binding, item)))))
30
32
  add('APP_SECRET_IN_CI', row.name, store.scope);
31
33
  if (entries.some((entry) => entry.name === row.name && matches(entry, store.scope)))
32
34
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "5.0.5",
3
+ "version": "5.0.6",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@ const PASSING = new Set(['success', 'neutral']);
8
8
  export const candidateRef = (tag, prefix = 'v') => `${CANDIDATE_PREFIX}${String(tag).startsWith(prefix) ? String(tag).slice(prefix.length) : String(tag)}`;
9
9
  function run(ctx, command, args) {
10
10
  try {
11
- return ctx.spawn(command, args, { cwd: ctx.cwd });
11
+ return ctx.spawn(command, args, { cwd: ctx.cwd, env: ctx.env });
12
12
  }
13
13
  catch {
14
14
  return { status: null };
@@ -72,6 +72,7 @@ export declare const refName: (value: unknown) => value is string;
72
72
  export declare function normalizeRelease(block: unknown, scripts?: Record<string, unknown> | undefined, services?: Services, options?: {
73
73
  example?: boolean;
74
74
  entities?: boolean;
75
+ repository?: string;
75
76
  }): {
76
77
  config: ReleaseConfig;
77
78
  problems?: undefined;
package/release/gate.mjs CHANGED
@@ -7,7 +7,9 @@ import { createHash } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
10
- import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
10
+ import { qualityServices, serviceBindings, projectServiceCredential, serviceCredentialNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
11
+ import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
12
+ import { serviceCredentialFile, serviceCredentialValue } from '../runtime/scripts/hfs/secret-store.mjs';
11
13
  import { openHfs } from '../runtime/scripts/hfs/slots.mjs';
12
14
  import { firstLine, lines, tool } from '../stack/context.mjs';
13
15
  import { failure, refusal } from '../stack/errors.mjs';
@@ -74,21 +76,72 @@ function auditGate(audit) {
74
76
  throw refusal(RELEASE_CODE, 'services.audit.level must be an npm audit level');
75
77
  return { name: 'audit', command: ['npm', 'audit', ...(audit.omitDev ? ['--omit=dev'] : []), `--audit-level=${audit.level}`], summary: null, env: {} };
76
78
  }
77
- /** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
78
- function sonarGate(services, env) {
79
+ /** The CI execution copy is job-local; a native owner run opens the selected product credential only when executed. */
80
+ function sonarGate(services, ctx, execute) {
81
+ const env = ctx.env;
79
82
  const configured = services?.sonar;
80
- const sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: 'https://sonarcloud.io' } : configured;
83
+ let sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: configured.host || 'https://sonarcloud.io' } : configured;
81
84
  const tokenName = sonar?.token;
82
- if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName])
83
- throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
84
- const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
85
+ if (!sonar || !ENV_KEY.test(tokenName))
86
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and ci.token selector');
87
+ let token;
88
+ let privateHost;
89
+ if (env.GITHUB_ACTIONS === 'true') {
90
+ token = env[tokenName];
91
+ if (!token)
92
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the declared Sonar CI execution copy is missing');
93
+ if (sonar.hostCredential) {
94
+ privateHost = env[sonar.hostVar || 'SONAR_HOST_URL'];
95
+ sonar = { ...sonar, host: privateHost ?? null, privateHost: true };
96
+ }
97
+ }
98
+ else {
99
+ const doc = services.declaration;
100
+ const repository = repositoryName(ctx.cwd, { env: identityQueryEnvironment(env, serviceCredentialNames(doc)) });
101
+ const selected = serviceBindings(doc, repository);
102
+ const binding = selected.bindings.find((item) => item.service === 'sonar' && item.name === tokenName);
103
+ if (selected.problems.length || !binding)
104
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare one explicit Sonar project credential binding in this product');
105
+ try {
106
+ serviceCredentialFile(ctx.cwd, binding);
107
+ if (execute)
108
+ token = serviceCredentialValue(ctx.cwd, binding, ctx.sops);
109
+ if (sonar.hostCredential) {
110
+ const endpoint = projectServiceCredential(doc, 'sonar', sonar.hostCredential, repository);
111
+ if (!endpoint)
112
+ throw new Error('invalid endpoint reference');
113
+ serviceCredentialFile(ctx.cwd, endpoint);
114
+ if (execute)
115
+ privateHost = serviceCredentialValue(ctx.cwd, endpoint, ctx.sops);
116
+ sonar = { ...sonar, host: privateHost ?? null, privateHost: true };
117
+ }
118
+ }
119
+ catch {
120
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the selected Sonar project custody is missing or cannot be opened');
121
+ }
122
+ }
123
+ if (!sonar.host && !(sonar.hostCredential && !execute))
124
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the declared Sonar connection URL is missing');
125
+ if (sonar.host) {
126
+ try {
127
+ const url = new URL(sonar.host);
128
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
129
+ throw new Error('invalid URL');
130
+ }
131
+ catch {
132
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the Sonar connection must be HTTP(S), with login kept in separate sealed credential keys');
133
+ }
134
+ }
135
+ const command = ['npx', '--yes', '@sonar/scan@4.3.0', ...(sonar.privateHost ? [] : [`-Dsonar.host.url=${sonar.host}`])];
136
+ if (sonar.projectKey)
137
+ command.push(`-Dsonar.projectKey=${sonar.projectKey}`);
85
138
  const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
86
139
  if (organization)
87
140
  command.push(`-Dsonar.organization=${organization}`);
88
141
  if (!command.every((word) => TOKEN.test(word)))
89
142
  throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
90
- return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar,
91
- credentialNames: [...new Set(['SONAR_TOKEN', tokenName])] };
143
+ return { name: 'sonar', command, summary: null, env: { ...(token === undefined ? {} : { SONAR_TOKEN: token }), ...(privateHost ? { SONAR_HOST_URL: privateHost } : {}) }, provider: sonar,
144
+ credentialNames: [...new Set(['SONAR_TOKEN', tokenName, ...serviceCredentialNames(services.declaration), ...(sonar.hostCredential ? ['SONAR_HOST_URL', sonar.hostVar] : [])].filter((name) => typeof name === 'string'))] };
92
145
  }
93
146
  function gateProblems(entry, at, seen) {
94
147
  const found = [];
@@ -261,7 +314,7 @@ async function sonarAvailability(ctx, gate) {
261
314
  }
262
315
  if (up)
263
316
  return null;
264
- return { pass: false, detail: `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds` };
317
+ return { pass: false, detail: `${SONAR_UNREACHABLE}: ${sonar.privateHost ? 'the declared private host' : sonar.host} did not report UP within ten seconds` };
265
318
  }
266
319
  /** An unreachable Sonar host stays a red row with a real log and no scan invocation. */
267
320
  export async function runAvailableGate(ctx, gate, options) {
@@ -292,12 +345,18 @@ async function judgeQualityGate(ctx, gate, row) {
292
345
  return row;
293
346
  const started = ctx.now();
294
347
  const verdict = await sonarQualityGate(ctx, { cwd: ctx.cwd, host: gate.provider.host, token: gate.env.SONAR_TOKEN });
295
- const failed = (verdict.conditions ?? []).map((line) => ` failed: ${line}\n`).join('');
296
- fs.appendFileSync(row.log, `\nquality gate: ${verdict.detail}\n${failed}`);
297
- return { ...row, pass: verdict.pass, detail: verdict.detail, seconds: row.seconds + Math.round((ctx.now() - started) / 1000), logSha256: sha256(row.log) };
348
+ const detail = redactGateText(gate, verdict.detail);
349
+ const failed = (verdict.conditions ?? []).map((line) => ` failed: ${redactGateText(gate, line)}\n`).join('');
350
+ fs.appendFileSync(row.log, `\nquality gate: ${detail}\n${failed}`);
351
+ return { ...row, pass: verdict.pass, detail, seconds: row.seconds + Math.round((ctx.now() - started) / 1000), logSha256: sha256(row.log) };
352
+ }
353
+ /** Scanner output and the later server verdict share one redaction boundary. */
354
+ function redactGateText(gate, text) {
355
+ const values = gate.provider ? [gate.env.SONAR_TOKEN, ...(gate.provider.privateHost ? [gate.env.SONAR_HOST_URL] : [])].filter((value) => Boolean(value)) : [];
356
+ return values.reduce((redacted, value) => redacted.replaceAll(value, '[REDACTED]'), text);
298
357
  }
299
358
  export const withoutCredentials = (values, credentialNames) => {
300
- const selected = new Set(credentialNames);
359
+ const selected = new Set([...credentialNames, 'SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE'].map((name) => name.toUpperCase()));
301
360
  return Object.fromEntries(Object.entries(values ?? {}).filter(([key]) => !selected.has(key.toUpperCase())));
302
361
  };
303
362
  export function runGate(ctx, gate, { dir, platform, credentialNames, preface = '' }) {
@@ -308,11 +367,10 @@ export function runGate(ctx, gate, { dir, platform, credentialNames, preface = '
308
367
  const env = { ...ctx.env, ...(gate.provider ? gate.env : withoutCredentials(gate.env, credentialNames)), ...RELEASE_CACHE_ENV };
309
368
  const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
310
369
  const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
311
- const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
370
+ const text = redactGateText(gate, output);
312
371
  fs.writeFileSync(log, preface + text);
313
372
  const verdict = judgeVerdict(run, gate, text);
314
- if (gate.name === 'sonar' && gate.env.SONAR_TOKEN)
315
- verdict.detail = verdict.detail.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]');
373
+ verdict.detail = redactGateText(gate, verdict.detail);
316
374
  const seconds = Math.round((ctx.now() - started) / 1000);
317
375
  return {
318
376
  name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
@@ -459,7 +517,7 @@ function backfillRed(ctx, { common, urgent, failed, openTicket }) {
459
517
  export async function releaseGate(ctx, config, options = {}) {
460
518
  // Sonar is a row of the managed CI, so it is a row of the local gate whenever the app declares services.sonar; a missing token is a refusal, never a skip.
461
519
  if (config.services?.sonar)
462
- config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
520
+ config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx, options.execute === true)] };
463
521
  const credentialNames = config.gates.find(gate => gate.provider)?.credentialNames ?? ['SONAR_TOKEN', config.services?.sonar?.token].filter(Boolean);
464
522
  ctx = Object.create(ctx, { env: { value: withoutCredentials(ctx.env, credentialNames) } });
465
523
  // The CI verdict of a release is the candidate run of its own sha, judged by `starci app release push` (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18):
@@ -42,13 +42,13 @@ export declare function contextOf(io?: VerbIo): VerbContext;
42
42
  export declare function readDeclaration(root: string): Record<string, any>;
43
43
  /** The normalised `release:` block (tolerant of absence) with the package version: { root, version, gates, tagPrefix, defaultBranch, requireCi }. */
44
44
  export declare function loadReleaseConfig(root: string): {
45
+ services: any;
45
46
  root: string;
46
47
  version: any;
47
48
  gates: import("./gate.mjs").Gate[];
48
49
  tagPrefix: string;
49
50
  defaultBranch: string;
50
51
  requireCi: boolean;
51
- services: any;
52
52
  };
53
53
  /** Parses the flags, runs `run(ctx, flags, positional)` and prints its result; returns the exit code. Only StackError is mapped, anything else is a bug and propagates. */
54
54
  export declare function runVerb({ label, name, io, argv, spec, run, code }: {
package/release/index.mjs CHANGED
@@ -10,11 +10,13 @@ import path from 'node:path';
10
10
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
11
11
  import { parseYaml } from '../runtime/engine/yaml.mjs';
12
12
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
13
+ import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
14
+ import { serviceCredentialNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
13
15
  import { hasEntityList } from '../db/entity-list.mjs';
14
16
  import { STACKS_DIR, STACKS_FILE } from '../stack/config.mjs';
15
17
  import { stackContext } from '../stack/context.mjs';
16
18
  import { EXIT, StackError, refusal } from '../stack/errors.mjs';
17
- import { RELEASE_CODE, VERSION, normalizeRelease, releaseGate } from './gate.mjs';
19
+ import { RELEASE_CODE, VERSION, normalizeRelease, releaseGate, withoutCredentials } from './gate.mjs';
18
20
  import { releasePush } from './push.mjs';
19
21
  import { releaseUrgentPush } from './urgent.mjs';
20
22
  const IO_KEYS = ['cwd', 'stdout', 'stderr', 'env', 'sops', 'hostIdentity'];
@@ -77,10 +79,11 @@ export function loadReleaseConfig(root) {
77
79
  if (typeof pkg.version !== 'string' || !VERSION.test(pkg.version))
78
80
  throw refusal(RELEASE_CODE, 'package.json needs a version like 1.2.3 (the release tag derives from it)');
79
81
  const declaration = readDeclaration(root);
80
- const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root), entities: hasEntityList(root) });
82
+ const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root), entities: hasEntityList(root),
83
+ repository: repositoryName(root, { env: identityQueryEnvironment(process.env, serviceCredentialNames(declaration)) }) });
81
84
  if (!config)
82
85
  throw refusal(RELEASE_CODE, `${problems.length} problem(s) in release: ${problems.slice(0, 6).join('; ')}`);
83
- return { root, version: pkg.version, ...config };
86
+ return { root, version: pkg.version, ...config, services: { ...config.services, declaration } };
84
87
  }
85
88
  function print(ctx, name, flags, result) {
86
89
  if (flags.json)
@@ -119,6 +122,7 @@ const ACTIONS = {
119
122
  if (flags.reason !== undefined && flags.urgent !== true)
120
123
  throw refusal('RELEASE_USAGE', '--reason belongs to an urgent release (--urgent)');
121
124
  const config = loadReleaseConfig(ctx.cwd);
125
+ ctx = Object.create(ctx, { env: { value: withoutCredentials(ctx.env, serviceCredentialNames(config.services?.declaration)) } });
122
126
  if (flags.urgent === true)
123
127
  return releaseUrgentPush(ctx, config, { execute: flags.execute === true, remote: flags.remote, reason: flags.reason, role: harness?.role ?? ctx.env.STARCI_ROLE });
124
128
  return releasePush(ctx, config, { execute: flags.execute === true, remote: flags.remote });