@starci/hfs 4.7.9 → 4.7.11

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.11 - 2026-10-19
4
+ - Changed: app release and Sonar adapters use native target binding, with local database migration and Docker transport preserving the declared stack target.
5
+
6
+ ## 4.7.10 - 2026-10-19
7
+ - Changed: bundled canon pins follow the regenerated Stylelint vocabulary and the matching lint canon cohort; HFS command behavior is unchanged.
8
+
3
9
  ## 4.7.9 - 2026-10-19
4
10
  - Changed: the lite scaffold README explicitly starts the Docker engine before the Supabase CLI-managed local stack and keeps application startup in its declared development profile.
5
11
 
@@ -9,6 +9,7 @@ import { databasePush } from '../runtime/scripts/api/supabase/database-push.mjs'
9
9
  import { isMigrationName, migrationStamp } from '../runtime/scripts/hfs/rules/database-migrations.mjs';
10
10
  import { CONFIG_FILE, MIGRATIONS_DIR } from '../runtime/scripts/hfs/rules/database-constants.mjs';
11
11
  import { failure, refusal } from '../stack/errors.mjs';
12
+ import { assertLocalDatabase, boundSupabaseLauncher, nativeDatabaseEnvironment } from '../stack/target-binding.mjs';
12
13
 
13
14
  const configError = (reason) => refusal('DB_CONFIG_INVALID', reason);
14
15
  const encrypted = (value) => typeof value === 'string' && value.includes('encrypted:');
@@ -96,7 +97,7 @@ function targetIdentity(config, env) {
96
97
  }
97
98
 
98
99
  /** Snapshot only in memory; no plaintext configuration, values or hashes reach the result. */
99
- function inputs(ctx) {
100
+ function inputs(ctx, nativeTarget = null) {
100
101
  const root = path.resolve(ctx.cwd);
101
102
  requireDirectory(root);
102
103
  requireDirectory(path.join(root, 'supabase'));
@@ -111,6 +112,10 @@ function inputs(ctx) {
111
112
  requirePlainConfig(config);
112
113
  for (const value of Object.values(ctx.env)) if (encrypted(value)) throw configError('local migration does not decrypt encrypted environment values');
113
114
  const { values: env, files: envFiles } = projectEnvironment(root, ctx.env);
115
+ if (nativeTarget && (config.experimental?.stack === true
116
+ || env.SUPABASE_EXPERIMENTAL_STACK && !/^(?:0|false)$/i.test(env.SUPABASE_EXPERIMENTAL_STACK)
117
+ || env.SUPABASE_CLI_BINARY_OVERRIDE)) throw configError('the bound local target requires the declared legacy Supabase backend and installed launcher');
118
+ const launcher = nativeTarget ? boundSupabaseLauncher(root) : null;
114
119
  const target = targetIdentity(config, env);
115
120
  let names;
116
121
  try { names = fs.readdirSync(path.join(root, MIGRATIONS_DIR)).sort(byteOrder); } catch { throw configError('local migrations are not readable'); }
@@ -127,18 +132,25 @@ function inputs(ctx) {
127
132
  migrations.push([name, readFile(path.join(root, MIGRATIONS_DIR, name))]);
128
133
  }
129
134
  const digest = createHash('sha256').update(JSON.stringify({ root, appText, configText, envFiles, env, target, migrations })).digest('hex');
130
- return { root, target, names: migrations.map(([name]) => name), digest };
135
+ return { root, target, names: migrations.map(([name]) => name), digest, nativeTarget, launcher };
131
136
  }
132
137
 
133
138
  function unchanged(ctx, snapshot) {
134
- const current = inputs(ctx);
139
+ const current = inputs(ctx, snapshot.nativeTarget);
135
140
  if (current.digest !== snapshot.digest) throw failure('DB_FINAL_MISMATCH', 'local migration inputs changed during the operation');
136
141
  return current;
137
142
  }
138
143
 
139
144
  /** Only the specific Supabase owner can spawn; failures and malformed JSON never expose child output. */
140
145
  function push(ctx, snapshot, dryRun) {
141
- const result = databasePush(snapshot.root, ['--local', '--skip-vault', '--workdir', snapshot.root, '--output-format', 'json', ...(dryRun ? ['--dry-run'] : ['--yes'])], { env: ctx.env, run: ctx.spawn });
146
+ const env = snapshot.nativeTarget ? nativeDatabaseEnvironment(ctx.env, snapshot.nativeTarget,
147
+ { 'SUPABASE_SERVICES_HOSTNAME': '127.0.0.1', 'SUPABASE_EXPERIMENTAL_STACK': '0' }) : ctx.env;
148
+ const run = snapshot.nativeTarget ? (file, args, options) => {
149
+ if (file !== process.execPath || args[0] !== snapshot.launcher) throw failure('DB_CLI_MISSING', 'the bound database launcher cannot fall back to a global tool');
150
+ return ctx.spawn(file, args, options);
151
+ } : ctx.spawn;
152
+ const result = databasePush(snapshot.root, ['--local', '--skip-vault', '--workdir', snapshot.root, '--output-format', 'json',
153
+ ...(snapshot.nativeTarget ? ['--profile', 'supabase'] : []), ...(dryRun ? ['--dry-run'] : ['--yes'])], { env, run });
142
154
  if (result?.error || result?.signal || result?.status !== 0) throw failure('DB_CLI_FAILED', 'the local Supabase migration command failed; database output was withheld');
143
155
  let payload;
144
156
  try { payload = JSON.parse(result.stdout); } catch { throw failure('DB_CLI_FAILED', 'the local migration command returned unreadable JSON'); }
@@ -156,8 +168,9 @@ function push(ctx, snapshot, dryRun) {
156
168
  }
157
169
 
158
170
  /** Preview by default; the caller admits execute only after the existing database arming check. No history repair, reset or linked fallback. */
159
- export function migrateLocalDatabase(ctx, { execute = false } = {}) {
160
- const snapshot = inputs(ctx);
171
+ export function migrateLocalDatabase(ctx, { execute = false, nativeTarget = null } = {}) {
172
+ const snapshot = inputs(ctx, nativeTarget);
173
+ assertLocalDatabase(nativeTarget, snapshot.target);
161
174
  const pending = push(ctx, snapshot, true);
162
175
  unchanged(ctx, snapshot);
163
176
  let applied = [];
package/db/migrate.mjs CHANGED
@@ -18,6 +18,7 @@ import { firstLine, tool } from '../stack/context.mjs';
18
18
  import { approvalMissing, failure, refusal } from '../stack/errors.mjs';
19
19
  import { readDeclaration, runVerb } from '../release/index.mjs';
20
20
  import { migrateLocalDatabase } from './local-migrate.mjs';
21
+ import { parseTargetBinding, assertUrlDatabase, assertBoundCustody, boundSupabaseLauncher, boundLinkedDatabaseUrl, nativeDatabaseEnvironment } from '../stack/target-binding.mjs';
21
22
 
22
23
  const DATABASE_CODE = 'DB_CONFIG_INVALID';
23
24
 
@@ -182,17 +183,20 @@ function parseConnection(value) {
182
183
  } catch { return null; }
183
184
  }
184
185
 
185
- function urlTarget(ctx, name) {
186
+ function urlTarget(ctx, name, binding = null) {
186
187
  if (!KEY.test(name)) throw refusal('DB_USAGE', '--url-env must name an UPPER_CASE environment variable');
187
188
  const value = ctx.env[name];
188
189
  if (typeof value !== 'string' || value === '') throw refusal('DB_URL_ENV_MISSING', `${name} is not set`);
190
+ if (binding && (binding.mode !== 'url-env' || name !== binding.urlEnv)) throw refusal('DB_WRONG_PROJECT', 'the URL selector differs from the accepted native target');
191
+ assertUrlDatabase(binding, value);
189
192
  const parsed = parseConnection(value);
190
193
  if (!parsed) throw refusal('DB_URL_INVALID', `${name} is not a postgres connection URL`);
191
194
  const secrets = [value, parsed.password].filter(Boolean);
192
- return { kind: 'url', how: `connection string from ${name}`, url: parsed.url, env: parsed.password ? { PGPASSWORD: parsed.password } : {}, secrets };
195
+ return { kind: 'url', how: `connection string from ${name}`, url: parsed.url, env: parsed.password ? { PGPASSWORD: parsed.password } : {}, secrets, nativeTarget: binding, privateInputEnv: name };
193
196
  }
194
197
 
195
- function sealedPassword(ctx, spec) {
198
+ function sealedPassword(ctx, spec, binding = null) {
199
+ if (binding) assertBoundCustody(ctx.cwd, path.join(ctx.cwd, STACKS_DIR, spec.env, 'secrets', `${spec.member}.enc`));
196
200
  let map;
197
201
  try { map = readMap(path.join(ctx.cwd, STACKS_DIR, spec.env, 'secrets', `${spec.member}.enc`), ctx.sops); } catch { throw refusal('DB_PASSWORD_UNREADABLE', `the sealed member ${spec.member} cannot be opened`); }
198
202
  if (typeof map[spec.key] !== 'string' || map[spec.key] === '') throw refusal('DB_PASSWORD_UNREADABLE', `the sealed member ${spec.member} has no value for ${spec.key}`);
@@ -200,24 +204,45 @@ function sealedPassword(ctx, spec) {
200
204
  }
201
205
 
202
206
  /** The database password for the linked CLI: SUPABASE_DB_PASSWORD from the environment, else the declared sealed value, else none (the CLI mints a login). */
203
- function linkedPassword(ctx, decl) {
207
+ function linkedPassword(ctx, decl, binding = null) {
208
+ if (binding) {
209
+ if (!decl.password || decl.password.env !== binding.environment || ctx.env.SUPABASE_DB_PASSWORD !== undefined)
210
+ throw refusal('DB_PASSWORD_UNREADABLE', 'the bound linked target requires its declared sealed password without an ambient override');
211
+ return sealedPassword(ctx, decl.password, binding);
212
+ }
204
213
  const given = ctx.env.SUPABASE_DB_PASSWORD;
205
214
  if (typeof given === 'string' && given !== '') return given;
206
215
  return decl.password === null ? null : sealedPassword(ctx, decl.password);
207
216
  }
208
217
 
209
- function linkedTarget(ctx, decl) {
218
+ function linkedTarget(ctx, decl, binding = null) {
219
+ if (binding && (binding.mode !== 'linked' || decl.projectRef !== binding.projectRef || !decl.password || decl.password.env !== binding.environment))
220
+ throw refusal('DB_WRONG_PROJECT', 'the linked project and sealed password must match the accepted native target');
210
221
  let ref;
222
+ if (binding) assertBoundCustody(ctx.cwd, path.join(ctx.cwd, 'supabase', '.temp', 'project-ref'));
211
223
  try { ref = fs.readFileSync(path.join(ctx.cwd, 'supabase', '.temp', 'project-ref'), 'utf8').trim(); } catch { throw refusal('DB_NOT_LINKED', 'no linked project (supabase/.temp/project-ref): run supabase link first'); }
212
224
  if (decl.projectRef !== null && ref !== decl.projectRef) throw refusal('DB_WRONG_PROJECT', `the app is linked to ${ref}, not the declared project ${decl.projectRef}`);
213
- const password = linkedPassword(ctx, decl);
214
- return { kind: 'linked', how: `linked project ${ref}`, ref, url: null, env: password ? { SUPABASE_DB_PASSWORD: password } : {}, secrets: password ? [password] : [] };
225
+ if (binding && ref !== binding.projectRef) throw refusal('DB_WRONG_PROJECT', 'the linked project differs from the accepted native target');
226
+ let nativeUrl;
227
+ if (binding) {
228
+ const saved = path.join(ctx.cwd, 'supabase', '.temp', 'pooler-url');
229
+ const stat = fs.lstatSync(saved, { throwIfNoEntry: false });
230
+ if (stat) {
231
+ assertBoundCustody(ctx.cwd, saved);
232
+ if (stat.size > 8192) throw refusal('DB_WRONG_PROJECT', 'the saved native pooler endpoint exceeds its public bound');
233
+ }
234
+ nativeUrl = boundLinkedDatabaseUrl(binding, stat ? fs.readFileSync(saved, 'utf8') : null);
235
+ }
236
+ const password = linkedPassword(ctx, decl, binding);
237
+ if (binding) return { kind: 'url', logicalKind: 'linked', how: `linked project ${ref}`, ref, url: nativeUrl,
238
+ env: { PGPASSWORD: password }, secrets: [password], nativeTarget: binding };
239
+ return { kind: 'linked', how: `linked project ${ref}`, ref, url: null, env: password ? { SUPABASE_DB_PASSWORD: password } : {}, secrets: password ? [password] : [], nativeTarget: binding };
215
240
  }
216
241
 
217
242
  /** { kind, how, env, secrets, url }: the linked project of the app (default) or the connection string in the named environment variable. */
218
243
  export function resolveTarget(ctx, decl, options) {
219
244
  if (options.linked === true && options.urlEnv !== undefined) throw refusal('DB_USAGE', '--linked and --url-env exclude each other');
220
- return options.urlEnv === undefined ? linkedTarget(ctx, decl) : urlTarget(ctx, options.urlEnv);
245
+ return options.urlEnv === undefined ? linkedTarget(ctx, decl, options.nativeTarget) : urlTarget(ctx, options.urlEnv, options.nativeTarget);
221
246
  }
222
247
 
223
248
  /** CLI arguments that select the database and the local supabase folder. */
@@ -228,7 +253,8 @@ export function requireArmed(ctx) {
228
253
  if (ctx.env.STARCI_DB_ARMED !== '1') throw approvalMissing('DB_NOT_ARMED', 'set STARCI_DB_ARMED=1 to apply database changes');
229
254
  }
230
255
 
231
- const supabaseCommand = (root) => {
256
+ const supabaseCommand = (root, bound) => {
257
+ if (bound) return [process.execPath, boundSupabaseLauncher(root)];
232
258
  const script = path.join(root, 'node_modules', 'supabase', 'dist', 'supabase.js');
233
259
  return fs.existsSync(script) ? [process.execPath, script] : ['supabase'];
234
260
  };
@@ -236,15 +262,41 @@ const supabaseCommand = (root) => {
236
262
  /** Runs the Supabase CLI through the seam; a failure names the first stderr line with every secret scrubbed. Returns { stdout, text } (text = stdout plus stderr). */
237
263
  export function runCli(session, args) {
238
264
  const { ctx, target } = session;
239
- const [command, ...lead] = supabaseCommand(ctx.cwd);
240
- const run = tool(ctx, command, [...lead, ...args], { cwd: ctx.cwd, env: { ...ctx.env, ...target.env } });
265
+ const [command, ...lead] = supabaseCommand(ctx.cwd, target.nativeTarget);
266
+ const env = target.nativeTarget ? nativeDatabaseEnvironment(ctx.env, target.nativeTarget, target.env, [target.privateInputEnv]) : { ...ctx.env, ...target.env };
267
+ const workdir = target.nativeTarget ? args[args.indexOf('--workdir') + 1] : ctx.cwd;
268
+ if (target.nativeTarget && !session.nativeWorkdirs?.has(workdir)) throw refusal('DB_CONFIG_INVALID', 'the bound database command requires its clean public scratch project');
269
+ const run = tool(ctx, command, [...lead, ...args, ...(target.nativeTarget ? ['--profile', 'supabase', '--output-format', 'json', '--agent', 'no'] : [])], { cwd: workdir, env });
241
270
  if (run.error) throw failure('DB_CLI_MISSING', `cannot run the Supabase CLI (${scrub(run.error.message, target.secrets)})`);
242
271
  if (run.status !== 0) throw failure('DB_CLI_FAILED', `supabase ${args.slice(0, 2).join(' ')} failed: ${scrub(firstLine(run.stderr), target.secrets)}`);
243
272
  return { stdout: run.stdout ?? '', text: `${run.stdout ?? ''}${run.stderr ?? ''}` };
244
273
  }
245
274
 
246
- /** The remote history as a Set of versions (read-only). */
247
- export const listRemote = (session) => parseMigrationList(runCli(session, ['migration', 'list', ...targetArgs(session.target, session.ctx.cwd)]).text).remote;
275
+ /** A bound consumer requires the native machine result; unreadable output cannot become an empty database history. */
276
+ function boundRemoteVersions(stdout) {
277
+ let data;
278
+ try { data = JSON.parse(stdout); } catch { throw failure('DB_CLI_FAILED', 'the bound migration history is not readable JSON'); }
279
+ if (!Array.isArray(data?.migrations) || data.migrations.some(item => !plain(item)
280
+ || typeof item.local !== 'string' || typeof item.remote !== 'string'
281
+ || item.local !== '' && !STAMP.test(item.local) || item.remote !== '' && !STAMP.test(item.remote)
282
+ || !item.local && !item.remote))
283
+ throw failure('DB_CLI_FAILED', 'the bound migration history has an unexpected shape');
284
+ return new Set(data.migrations.map(item => item.remote).filter(Boolean));
285
+ }
286
+
287
+ /** The remote history as a Set of versions; bound callers require noninteractive structured output. */
288
+ export function listRemote(session) {
289
+ if (!session.target.nativeTarget) return parseMigrationList(runCli(session, ['migration', 'list', ...targetArgs(session.target, session.ctx.cwd)]).text).remote;
290
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-db-'));
291
+ try {
292
+ const { model } = session;
293
+ buildScratch(session, scratch, model.push.map(file => ({ from: path.join(model.root, model.decl.migrationsDir, file), name: file })));
294
+ return boundRemoteVersions(runCli(session, ['migration', 'list', ...targetArgs(session.target, scratch)]).stdout);
295
+ } finally {
296
+ session.nativeWorkdirs?.delete(scratch);
297
+ fs.rmSync(scratch, { recursive: true, force: true });
298
+ }
299
+ }
248
300
 
249
301
  /* ---- the plan */
250
302
 
@@ -288,12 +340,13 @@ const dataOf = (model, plan, remote, extra) => ({
288
340
 
289
341
  /* ---- applying */
290
342
 
291
- /** A scratch supabase project holding exactly the given migration files; the link state of the app is copied so --linked works from it. */
343
+ /** Public scratch SQL/config only for bound URLs; ordinary linked commands retain their native link state. */
292
344
  function buildScratch(session, dir, copies) {
293
345
  fs.mkdirSync(path.join(dir, 'supabase', 'migrations'), { recursive: true });
294
346
  fs.writeFileSync(path.join(dir, 'supabase', 'config.toml'), 'project_id = "starci-db-scratch"\n');
295
347
  for (const copy of copies) fs.copyFileSync(copy.from, path.join(dir, 'supabase', 'migrations', copy.name));
296
348
  if (session.target.kind === 'linked') fs.cpSync(path.join(session.ctx.cwd, 'supabase', '.temp'), path.join(dir, 'supabase', '.temp'), { recursive: true });
349
+ if (session.target.nativeTarget) (session.nativeWorkdirs ??= new Set()).add(dir);
297
350
  }
298
351
 
299
352
  const repair = (session, status, versions, dir) => runCli(session, ['migration', 'repair', '--status', status, ...versions, ...targetArgs(session.target, dir)]);
@@ -334,6 +387,7 @@ function applyPlan(session, plan) {
334
387
  * STARCI_DB_ARMED=1) applies it. Already baseline + forward: `nothing to do`, no mutating call, ok.
335
388
  */
336
389
  async function migrateDatabase(ctx, decl, options = {}) {
390
+ if (options.nativeTarget) boundSupabaseLauncher(ctx.cwd);
337
391
  const model = buildModel(ctx.cwd, decl);
338
392
  const session = { ctx, model, target: resolveTarget(ctx, decl, options) };
339
393
  const remote = listRemote(session);
@@ -354,19 +408,22 @@ async function migrateDatabase(ctx, decl, options = {}) {
354
408
  return { ok: true, lines: [...lines, `applied: ${pushed.length} forward migration(s) pushed; the remote history now holds ${final.size} version(s). Held back: ${model.held.join(', ') || 'nothing'}.`], data: dataOf(model, plan, remote, { executed: true, pushed, remoteCountAfter: final.size }) };
355
409
  }
356
410
 
357
- const SPEC = { boolean: ['--execute', '--json', '--linked', '--local'], value: ['--url-env'] };
411
+ const SPEC = { boolean: ['--execute', '--json', '--linked', '--local'], value: ['--url-env', '--target-binding'] };
358
412
 
359
413
  /** Entry of the verb: explicit local applies active SQL; linked/url-env retains the declared cutover plan. Execute requires database arming. */
360
414
  export function dbMigrateMain(argv, io = {}) {
361
415
  return runVerb({
362
416
  label: 'starci app db migrate', name: 'db migrate', code: 'DB_USAGE', io, argv, spec: SPEC,
363
417
  run: (ctx, flags) => {
418
+ const nativeTarget = parseTargetBinding(flags['target-binding'], 'supabase-db');
419
+ if (nativeTarget && (nativeTarget.mode === 'local' ? !flags.local : nativeTarget.mode === 'linked' ? !flags.linked : flags['url-env'] !== nativeTarget.urlEnv))
420
+ throw refusal('DB_USAGE', 'the native database selector differs from its accepted binding');
364
421
  if (flags.local) {
365
422
  if (flags.linked || flags['url-env'] !== undefined) throw refusal('DB_USAGE', '--local excludes --linked and --url-env');
366
423
  if (flags.execute) requireArmed(ctx);
367
- return migrateLocalDatabase(ctx, { execute: flags.execute === true });
424
+ return migrateLocalDatabase(ctx, { execute: flags.execute === true, nativeTarget });
368
425
  }
369
- return migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, linked: flags.linked === true, urlEnv: flags['url-env'] });
426
+ return migrateDatabase(ctx, loadDatabase(ctx.cwd), { execute: flags.execute === true, linked: flags.linked === true, urlEnv: flags['url-env'], nativeTarget });
370
427
  },
371
428
  });
372
429
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.9",
3
+ "version": "4.7.11",
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",
package/release/gate.mjs CHANGED
@@ -71,7 +71,8 @@ function sonarGate(services, env) {
71
71
  const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
72
72
  if (organization) command.push(`-Dsonar.organization=${organization}`);
73
73
  if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
74
- return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar };
74
+ return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar,
75
+ credentialNames: [...new Set(['SONAR_TOKEN', tokenName])] };
75
76
  }
76
77
 
77
78
  function gateProblems(entry, at, seen) {
@@ -128,7 +129,7 @@ export function normalizeRelease(block, scripts = {}, services = {}, options = {
128
129
 
129
130
  /** Runs git in the app root; a missing git or (unless allowFail) a non-zero exit is a failure whose message names the first stderr line. */
130
131
  export function git(ctx, args, { allowFail = false } = {}) {
131
- const run = tool(ctx, 'git', args, { cwd: ctx.cwd });
132
+ const run = tool(ctx, 'git', args, { cwd: ctx.cwd, env: ctx.env });
132
133
  if (run.error) throw failure('RELEASE_GIT_MISSING', `cannot run git (${run.error.message})`);
133
134
  if (run.status !== 0 && !allowFail) throw failure('RELEASE_GIT_FAILED', `git ${args.slice(0, 2).join(' ')}: ${firstLine(run.stderr)}`);
134
135
  return { code: run.status, stdout: String(run.stdout ?? '').trim(), stderr: String(run.stderr ?? '') };
@@ -236,11 +237,18 @@ async function runAvailableGate(ctx, gate, options) {
236
237
  summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
237
238
  }
238
239
 
239
- function runGate(ctx, gate, { dir, platform }) {
240
+ const withoutCredentials = (values, credentialNames) => {
241
+ const selected = new Set(credentialNames);
242
+ return Object.fromEntries(Object.entries(values ?? {}).filter(([key]) => !selected.has(key.toUpperCase())));
243
+ };
244
+
245
+ function runGate(ctx, gate, { dir, platform, credentialNames }) {
240
246
  const log = path.join(dir, `${gate.name}.log`);
241
247
  const started = ctx.now();
242
248
  const [command, args] = launch(gate.command, platform);
243
- const run = tool(ctx, command, args, { cwd: ctx.cwd, env: { ...ctx.env, ...gate.env } });
249
+ // The native scanner alone consumes Sonar credentials; ordinary gates cannot inherit or reintroduce them.
250
+ const env = { ...ctx.env, ...(gate.provider ? gate.env : withoutCredentials(gate.env, credentialNames)) };
251
+ const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
244
252
  const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
245
253
  const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
246
254
  fs.writeFileSync(log, text);
@@ -345,6 +353,8 @@ function summaryOf(result) {
345
353
  */
346
354
  export async function releaseGate(ctx, config, options = {}) {
347
355
  if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
356
+ const credentialNames = config.gates.find(gate => gate.provider)?.credentialNames ?? ['SONAR_TOKEN', config.services?.sonar?.token].filter(Boolean);
357
+ ctx = Object.create(ctx, {env: {value: withoutCredentials(ctx.env, credentialNames)}});
348
358
  // 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):
349
359
  // the gate no longer refuses over the latest default-branch run.
350
360
  const facts = inspect(ctx, config, options);
@@ -355,7 +365,7 @@ export async function releaseGate(ctx, config, options = {}) {
355
365
  const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
356
366
  if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
357
367
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
358
- const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform });
368
+ const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform, credentialNames });
359
369
  const report = results.map(summaryOf);
360
370
  const failed = results.filter((result) => !result.pass && !result.skipped);
361
371
  if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
@@ -29,21 +29,21 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.15
32
+ version: 3.4.17
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.15
39
+ version: 8.4.17
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
43
43
  source: packages/eslint/fe/package.json
44
44
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
45
45
  '@starci/stylelint-canon':
46
- version: 2.0.9
46
+ version: 2.0.10
47
47
  group: starci
48
48
  install: registry
49
49
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.7.9
79
+ version: 1.7.11
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.7.9
86
+ version: 4.7.11
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -105,6 +105,8 @@ properties:
105
105
  status: {enum: [deferred, supported]}
106
106
  reason: {type: string, minLength: 1}
107
107
  ci: {$ref: '#/$defs/ciDeclaration'}
108
+ operations:
109
+ $ref: provision-operation.schema.yaml
108
110
  services:
109
111
  description: >-
110
112
  The delivery, quality and platform services this repository's code and CI use - NOT Compose