@starci/hfs 4.16.0 → 4.16.2

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,10 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.16.2 - 2026-10-31
4
+
5
+ - Fixed: R248 `ENV_DUPLICATE` judges app env only. The operator's SSH member (`deploy.remote.sealed`) and the CI member `vps-ci` are separate identities by design (ruling ci-transport-one-home) and never reach an app container, so the SSH group keys (`SSH_GROUP`, now exported from `secret-standard`) held by exactly those two members of one environment are no duplicate; an app key in two members, or an SSH group key in any third member, stays red.
6
+ - Changed: the CI transport lives in the sealed `vps-ci` alone and GitHub keeps only `SOPS_AGE_KEY`: `stack-key-restore.yml` (both editions) opens the sealed member with the same `stack connect` step as the deploy instead of `VPS_*` environment secrets, and the starters no longer declare `VPS_SSH_KEY`, `VPS_KNOWN_HOSTS`, `VPS_HOST` and `VPS_USER` under `ci:`.
7
+ - Added: the ssh gate operation `identity-restore <recipient>` (stdin: the identity; writes only the fixed server identity path, once, for the matching recipient), so the restore workflow is admitted when the CI key is forced through `starci-ssh-gate`; `CI_MEMBER` moves to `sealed-member`.
8
+ - Fixed: R259 `DB_MIGRATION_DESTRUCTIVE` leaves out `SET NOT NULL` and a type change on a column (or table) the same migration created earlier, which touches no existing data, and an entity references a table only by the name its `@Entity`/`@Table` decorator spells: a column property of the same word (`queue`) is no reference to a dropped table `queue`; an entity that spells no name keeps the identifier match.
9
+ - Fixed: `HFS_SCHEDULE_UNDECLARED` judges unparsable SQL only when it calls `cron.schedule`, `cron.unschedule`, `cron.schedule_in_database` or `cron.alter_job`; a statement of another dialect (MySQL session setup) schedules nothing.
10
+ - Changed: the bundled canon pins name hfs 4.16.2, @starci/grammar 0.19.1, eslint-canon-be 3.11.3, eslint-canon-fe 9.8.2 and eslint-grammar-core and eslint-grammar-offset-pop 0.19.1.
11
+
12
+ ## 4.16.1 - 2026-10-31
13
+
14
+ - Changed: the bundled canon pins name @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.19.0, eslint-canon-be 3.11.2, eslint-canon-fe 9.8.1, stylelint-canon 2.4.0 and hfs 4.16.1 (RELEASE_VERSION_NOT_BUMPED: 4.16.0 is on npm).
15
+
3
16
  ## 4.16.0 - 2026-10-31
4
17
 
5
18
  - Added: `starci app codemod spacing-roles [--execute] [--json] [--format json|markdown] [--out <file>]`: a dry run by default; plain flex column and row `div`s whose only spacing is one gap become `Stack` and `Cluster` bound from the file's Grammar import (gap-1 identity, gap-2 inline, gap-3 row, gap-4 section, gap-6 and gap-8 region, gap-8 marked for review); every other spacing utility is reported with its suggested role, and the report counts the utilities by family.
6
19
  - Changed: `starci app lint` reports an ESLint warning (a rule of the canon's counting cohort) with severity `warning`, counts them per rule (`counts.warning`, `counting`) and keeps them out of the verdict and the error threshold.
7
- - Changed: the canon pins name @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.18.0, eslint-canon-fe 9.8.0, stylelint-canon 2.4.0 and hfs 4.16.0; R350 joins the bundled rule catalog.
20
+ - Changed: the canon pins name @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.19.0, eslint-canon-fe 9.8.0, stylelint-canon 2.4.0 and hfs 4.16.0; R350 joins the bundled rule catalog.
8
21
 
9
22
  ## 4.15.0 - 2026-10-31
10
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.16.0",
3
+ "version": "4.16.2",
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",
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.18.0
34
+ version: 0.19.1
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  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.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.11.1
41
+ version: 3.11.3
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  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.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.8.0
48
+ version: 9.8.2
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  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.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.18.0
55
+ version: 0.19.1
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.18.0
62
+ version: 0.19.1
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.16.0
109
+ version: 4.16.2
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -1989,6 +1989,7 @@ ENV_DUPLICATE:
1989
1989
  causes_vi:
1990
1990
  - "Vi phạm luật R248: một khoá chỉ sống trong một member; hai bản sao lệch giá trị là lỗi vận hành chờ xảy ra."
1991
1991
  - "Khoá được di chuyển sang member mới nhưng quên gỡ khỏi member cũ."
1992
+ - "Chỉ các khoá nhóm SSH (`VPS_HOST`, `VPS_USER`, `privateKey`, `knownHosts`) được phép nằm ở cả member `deploy.remote.sealed` lẫn `vps-ci` của cùng môi trường; mọi khoá app khác, hoặc khoá SSH ở member thứ ba, vẫn bị báo."
1992
1993
  nextStep_vi: "Giữ khoá ở đúng một member và gỡ nó khỏi member còn lại (`starci app secret set` rồi chỉnh lại member thừa)."
1993
1994
  owner: op-retry
1994
1995
  kind: check-finding
@@ -369,7 +369,7 @@ function columnAlterObject(tokens, index, table, line) {
369
369
  && sqlWord(tokens[at + 2 + Number(sqlWord(tokens[at + 1], 'column'))], 'set') && sqlWord(tokens[at + 3 + Number(sqlWord(tokens[at + 1], 'column'))], 'default'));
370
370
  if (!type && !(notNull && !defaultSet))
371
371
  return null;
372
- return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', line };
372
+ return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', alters: true, line };
373
373
  }
374
374
  function alteredColumn(table, column, line) {
375
375
  return { name: `${table.name}.${column.name}`, leaf: column.leaf, table: table.leaf, kind: 'column', line };
@@ -467,11 +467,32 @@ function globalConstraintNames(statement) {
467
467
  }
468
468
  return names;
469
469
  }
470
+ const ENTITY_DECORATORS = new Set(['Entity', 'Table', 'ViewEntity']);
471
+ /** Adds the table names a class decorator `@Entity(...)`, `@Table(...)` or `@ViewEntity(...)` spells to `tables`; true when such a decorator spells none. */
472
+ function noteEntityTables(ts, decorator, tables) {
473
+ const call = decorator.expression;
474
+ const callee = ts.isCallExpression(call) ? call.expression : call;
475
+ if (!ts.isIdentifier(callee) || !ENTITY_DECORATORS.has(callee.text))
476
+ return false;
477
+ const before = tables.size;
478
+ const collect = (node) => {
479
+ if (ts.isStringLiteralLike(node))
480
+ for (const part of [node.text, node.text.split('.').at(-1)])
481
+ tables.add(part);
482
+ ts.forEachChild(node, collect);
483
+ };
484
+ if (ts.isCallExpression(call))
485
+ for (const argument of call.arguments)
486
+ collect(argument);
487
+ return tables.size === before;
488
+ }
470
489
  function sourceNameScopes(ts, text, file) {
471
490
  const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
472
491
  const checker = lexicalChecker(ts, source);
473
492
  const scopes = [];
474
493
  const entityNames = /\.entity\.[cm]?tsx?$/.test(file) ? new Set() : null;
494
+ const entityTables = new Set();
495
+ let implicitTable = false;
475
496
  const addSql = (sql) => {
476
497
  for (const statement of databaseSqlStatements(databaseSqlTokens(sql))) {
477
498
  const names = new Set(statement.filter((token) => sqlName(token) !== null).map((token) => token.v));
@@ -490,10 +511,15 @@ function sourceNameScopes(ts, text, file) {
490
511
  addSql(sql);
491
512
  if (entityNames && ts.isIdentifier(node))
492
513
  entityNames.add(node.text);
514
+ if (entityNames && ts.isDecorator(node))
515
+ implicitTable = noteEntityTables(ts, node, entityTables) || implicitTable;
493
516
  ts.forEachChild(node, visit);
494
517
  };
495
518
  visit(source);
496
- return entityNames ? [{ names: entityNames, constraints: entityNames, globalConstraints: new Set() }] : scopes;
519
+ // An entity names its table by the string of its @Entity/@Table decorator; a column property of the same word (`queue`) is no table reference.
520
+ // An entity that names none (or has no such decorator) keeps the conservative identifier match.
521
+ const tables = entityTables.size > 0 && !implicitTable ? entityTables : undefined;
522
+ return entityNames ? [{ names: entityNames, tables, constraints: entityNames, globalConstraints: new Set() }] : scopes;
497
523
  }
498
524
  /**
499
525
  * Whether a be/src source references a destroyed object. A column (`<schema>.<table>.<column>`) is referenced only by a source that
@@ -501,9 +527,10 @@ function sourceNameScopes(ts, text, file) {
501
527
  * SQL constraints require explicit constraint names, while entities retain their table/decorator association.
502
528
  * Other object kinds match their leaf name conservatively.
503
529
  */
504
- const referencesObject = (scopes, object) => scopes.some(({ names, constraints, globalConstraints }) => (object.kind === 'constraint' && globalConstraints.has(object.leaf))
505
- || ((object.kind === 'constraint' ? constraints : names).has(object.leaf)
506
- && (!['column', 'constraint'].includes(object.kind) || !object.table || names.has(object.table))));
530
+ const referencesObject = (scopes, object) => scopes.some(({ names, tables, constraints, globalConstraints }) => (object.kind === 'constraint' && globalConstraints.has(object.leaf))
531
+ || (object.kind === 'table' && tables !== undefined ? tables.has(object.leaf)
532
+ : (object.kind === 'constraint' ? constraints : names).has(object.leaf)
533
+ && (!['column', 'constraint'].includes(object.kind) || !object.table || (tables ?? names).has(object.table))));
507
534
  function destructiveObjectFinding(file, object, contract, references) {
508
535
  const reference = references.find(({ scopes }) => referencesObject(scopes, object));
509
536
  if (contract && !reference)
@@ -512,10 +539,50 @@ function destructiveObjectFinding(file, object, contract, references) {
512
539
  : 'use a *_contract_* migration after the application stops using this object';
513
540
  return found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destructively changes ${object.name}; ${reason}`, { line: object.line, object: object.name, ...(reference ? { reference: reference.file } : {}) });
514
541
  }
542
+ /** The `<table leaf>.<column leaf>` keys an ALTER TABLE ... ADD COLUMN statement adds, and `<table leaf>.*` for a CREATE TABLE. */
543
+ function addedColumns(statement) {
544
+ const added = [];
545
+ for (let index = 0; index < statement.length - 1; index += 1) {
546
+ if (sqlWord(statement[index], 'create')) {
547
+ const at = skipWords(statement, index + 1, ['temp', 'temporary', 'unlogged']);
548
+ const created = sqlWord(statement[at], 'table') ? nameAt(statement, skipWords(statement, at + 1, ['if', 'not', 'exists'])) : null;
549
+ if (created)
550
+ added.push(`${created.leaf}.*`);
551
+ }
552
+ if (!(sqlWord(statement[index], 'alter') && sqlWord(statement[index + 1], 'table')))
553
+ continue;
554
+ const table = nameAt(statement, skipWords(statement, index + 2, ['if', 'exists', 'only']));
555
+ if (!table)
556
+ continue;
557
+ const body = statement.slice(table.next);
558
+ body.forEach((token, at) => {
559
+ if (!sqlWord(token, 'add'))
560
+ return;
561
+ const start = skipWords(body, at + 1, ['column', 'if', 'not', 'exists']);
562
+ if (['constraint', 'primary', 'foreign', 'unique', 'check', 'exclude'].some((word) => sqlWord(body[start], word)))
563
+ return;
564
+ const column = nameAt(body, start);
565
+ if (column)
566
+ added.push(`${table.leaf}.${column.leaf}`);
567
+ });
568
+ }
569
+ return added;
570
+ }
571
+ /** An ALTER of a column (SET NOT NULL, TYPE) the same migration added earlier changes no existing data: it is not destructive. */
572
+ const alterOfNewColumn = (object, added) => object.alters === true && (added.has(`${object.table}.${object.leaf}`) || added.has(`${object.table}.*`));
573
+ /** The destructive objects of one migration's SQL, in order, leaving out the alters of columns and tables the migration created itself. */
574
+ function migrationDestructiveObjects(text) {
575
+ const added = new Set();
576
+ return databaseSqlStatements(databaseExecutableTokens(text)).flatMap((statement) => {
577
+ for (const key of addedColumns(statement))
578
+ added.add(key);
579
+ return destructiveObjects(statement).filter((object) => !alterOfNewColumn(object, added));
580
+ });
581
+ }
515
582
  function destructiveFileFindings(file, text, references) {
516
583
  const contract = /_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*\.sql$/.test(file);
517
584
  const seen = new Set();
518
- const objects = databaseSqlStatements(databaseExecutableTokens(text)).flatMap(destructiveObjects);
585
+ const objects = migrationDestructiveObjects(text);
519
586
  return objects.flatMap((object) => {
520
587
  const key = `${object.line}:${object.kind}:${object.name}`;
521
588
  if (seen.has(key))
@@ -564,7 +631,7 @@ export async function contractReleaseFindings({ repoRoot, migrations, git }) {
564
631
  const text = readText(repoRoot, file);
565
632
  if (text === null)
566
633
  return [];
567
- return databaseSqlStatements(databaseExecutableTokens(text)).flatMap(destructiveObjects).flatMap((object) => {
634
+ return migrationDestructiveObjects(text).flatMap((object) => {
568
635
  const reference = references.find(({ scopes }) => referencesObject(scopes, object));
569
636
  return reference ? [found(DB_MIGRATION_DESTRUCTIVE, file, `${file}:${object.line} destroys ${object.name}, which the previous release ${release} still references in ${reference.file}; the contract migration runs right after the accept while that release is the rollback target: ship the code change in one release and this contract migration in the next`, { line: object.line, object: object.name, release, reference: reference.file })] : [];
570
637
  });
@@ -13,7 +13,8 @@
13
13
  // `.starcistacks/<env>/runtime/env/KEYS.md`, and the `environment:`/`env:` entries, `runtimeSecrets`, `secrets`
14
14
  // and `vars[].name` of every `.starcistacks` yaml. A `<KEY>_FILE` name declares `<KEY>`.
15
15
  // Findings: ENV_REQUIRED_UNSET a required read sits in neither S nor D; ENV_SEALED_UNREAD a sealed key sits in
16
- // neither R nor a declared consumer; ENV_DUPLICATE one key in two members of one environment; ENV_PUBLIC_SEALED a NEXT_PUBLIC_* key in
16
+ // neither R nor a declared consumer; ENV_DUPLICATE one app key in two members of one environment (the transport members, the operator's `deploy.remote.sealed` member and
17
+ // `vps-ci`, never reach an app container and may both hold the SSH_GROUP keys, nothing else); ENV_PUBLIC_SEALED a NEXT_PUBLIC_* key in
17
18
  // custody; ENV_EXAMPLE_DRIFT a `*.env.example` key nothing reads. The rule judges the app root (`isRoot`): the whole
18
19
  // tree `all` is its input, both editions.
19
20
  import path from 'node:path';
@@ -21,6 +22,8 @@ import { fileURLToPath } from 'node:url';
21
22
  import { loadTypescript } from '../../lib/package-at.mjs';
22
23
  import { parseYaml } from '../../../engine/yaml.mjs';
23
24
  import { envelopeOf } from '../secret-document.mjs';
25
+ import { SSH_GROUP, sshTransportMembers } from '../secret-standard.mjs';
26
+ import { memberOfName } from '../../lib/sealed-member.mjs';
24
27
  import { found, readText } from './read.mjs';
25
28
  export const ENV_REQUIRED_UNSET = 'ENV_REQUIRED_UNSET';
26
29
  export const ENV_SEALED_UNREAD = 'ENV_SEALED_UNREAD';
@@ -349,25 +352,39 @@ function sealedMemberFinding(key, member, reads, declared) {
349
352
  }
350
353
  return null;
351
354
  }
352
- function addSealedFindings(sealed, reads, declared, findings) {
355
+ /** The transport members per environment: the `deploy.remote.sealed` member(s) of application-stacks.yaml and `vps-ci`. */
356
+ function transportMembers(repoRoot) {
357
+ const text = readText(repoRoot, '.starcistacks/application-stacks.yaml');
358
+ let stacks = {};
359
+ try {
360
+ stacks = text === null ? {} : parseYaml(text) ?? {};
361
+ }
362
+ catch { /* a malformed stacks yaml is the shape rule's finding, not this one's */ }
363
+ return (environment) => sshTransportMembers(stacks.environments?.[environment]);
364
+ }
365
+ function addSealedFindings(sealed, reads, declared, transport, findings) {
353
366
  for (const [key, members] of sealed) {
354
367
  for (const member of members) {
355
368
  const finding = sealedMemberFinding(key, member, reads, declared);
356
369
  if (finding !== null)
357
370
  findings.push(finding);
358
371
  }
359
- addDuplicateFindings(key, members, findings);
372
+ addDuplicateFindings(key, members, transport, findings);
360
373
  }
361
374
  }
362
- function addDuplicateFindings(key, members, findings) {
375
+ /** One key in two members of one environment, except an SSH_GROUP key held by the two transport members (a classification: those never reach an app). */
376
+ function addDuplicateFindings(key, members, transport, findings) {
363
377
  const firstByEnvironment = new Map();
364
378
  for (const member of members) {
365
379
  const environment = member.split('/')[1];
380
+ const exempt = (file) => SSH_GROUP.includes(key) && transport(environment).has(memberOfName(file.split('/').pop()));
366
381
  const first = firstByEnvironment.get(environment);
367
382
  if (first === undefined) {
368
383
  firstByEnvironment.set(environment, member);
369
384
  continue;
370
385
  }
386
+ if (exempt(first) && exempt(member))
387
+ continue;
371
388
  findings.push(found(ENV_DUPLICATE, member, `${key} is sealed in ${first} and again in ${member}; one key lives in one member per environment`));
372
389
  }
373
390
  }
@@ -394,7 +411,7 @@ export function envParityFindings({ repoRoot, all = [], isRoot }) {
394
411
  const declared = declaredKeys(repoRoot, all);
395
412
  const findings = [];
396
413
  addRequiredFindings(reads, sealed, declared, findings);
397
- addSealedFindings(sealed, reads, declared, findings);
414
+ addSealedFindings(sealed, reads, declared, transportMembers(repoRoot), findings);
398
415
  addExampleFindings(repoRoot, all, reads, findings);
399
416
  return findings;
400
417
  }
@@ -245,6 +245,14 @@ function executableExpression(expression) {
245
245
  }
246
246
  return `SELECT ${query}`;
247
247
  }
248
+ const PG_CRON_CALL = /\bcron\s*\.\s*(?:schedule|unschedule|schedule_in_database|alter_job)\b/i;
249
+ /**
250
+ * SQL Postgres cannot parse is judged only when it names a pg_cron call: a statement of another dialect (MySQL session setup, say) schedules nothing, so
251
+ * it is no unknown job, while unparsable text that does call cron.schedule stays an undeclared job nobody can read.
252
+ */
253
+ function unparsedSchedule(text, file) {
254
+ return PG_CRON_CALL.test(text) ? [{ id: null, cadence: null, at: file }] : [];
255
+ }
248
256
  function doSqlObservations(statement, file) {
249
257
  const body = statement.args?.find((arg) => arg.DefElem?.defname === 'as')?.DefElem?.arg?.String?.sval;
250
258
  if (!present(body))
@@ -254,7 +262,7 @@ function doSqlObservations(statement, file) {
254
262
  tree = pg.parsePlPgSQLSync("DO '" + body.replaceAll("'", "''") + "';");
255
263
  }
256
264
  catch {
257
- return [{ id: null, cadence: null, at: file }];
265
+ return unparsedSchedule(body, file);
258
266
  }
259
267
  const observations = [];
260
268
  walkSql(tree, (value) => {
@@ -267,7 +275,7 @@ function doSqlObservations(statement, file) {
267
275
  function sqlObservations(text, file) {
268
276
  const tree = parseSql(text);
269
277
  if (!tree)
270
- return [{ id: null, cadence: null, at: file }];
278
+ return unparsedSchedule(text, file);
271
279
  const observations = [];
272
280
  walkSql(tree, (value) => {
273
281
  if (value.DoStmt)
@@ -338,7 +346,7 @@ function templateObservations(node, file) {
338
346
  const named = spell(DYNAMIC);
339
347
  const tree = parseSql(named);
340
348
  if (!tree)
341
- return [{ id: null, cadence: null, at: file }];
349
+ return unparsedSchedule(named, file);
342
350
  let dynamicCall = false;
343
351
  walkSql(tree, (value) => {
344
352
  if (value.FuncCall && (value.FuncCall.funcname ?? []).some((part) => part.String?.sval === DYNAMIC))
@@ -46,6 +46,13 @@ 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
+ /** The keys of the one sealed SSH member `deploy.remote.sealed` names (stack/remote.mjs): the deploy transport reads exactly these. */
50
+ export declare const SSH_GROUP: readonly string[];
51
+ /**
52
+ * The two transport members of an environment (ruling ci-transport-one-home): the operator's member(s) `deploy.remote.sealed` names and
53
+ * the CI member `vps-ci`, two identities by design, neither of which reaches an app container. Only the SSH_GROUP keys may appear in both.
54
+ */
55
+ export declare function sshTransportMembers(spec: EnvironmentSpec | undefined): Set<string>;
49
56
  /**
50
57
  * Every deviation of the app at `repoRoot` (one environment, else each with a secrets folder): LEGACY_MEMBER, MIXED_PROVIDERS,
51
58
  * RECIPIENT_DRIFT, UNDECLARED_KEY (only where application-stacks.yaml declares the environment), UNTRACKED. Names only.
@@ -8,7 +8,7 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { parseYaml } from "../../engine/yaml.mjs";
10
10
  import { lsFiles } from "../api/git/ls-files.mjs";
11
- import { MEMBER_SUFFIX, isLegacyName, memberOfName } from "../lib/sealed-member.mjs";
11
+ import { CI_MEMBER, MEMBER_SUFFIX, isLegacyName, memberOfName } from "../lib/sealed-member.mjs";
12
12
  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";
@@ -27,7 +27,16 @@ function declaredKeys(spec, member, keys) {
27
27
  return entries.filter((entry) => entry?.key !== undefined && memberOfEntry(entry) === member).map((entry) => entry.key);
28
28
  }
29
29
  /** The keys of the one sealed SSH member `deploy.remote.sealed` names (stack/remote.mjs): the deploy transport reads exactly these. */
30
- const SSH_GROUP = Object.freeze(["VPS_HOST", "VPS_USER", "privateKey", "knownHosts"]);
30
+ export const SSH_GROUP = Object.freeze(["VPS_HOST", "VPS_USER", "privateKey", "knownHosts"]);
31
+ /**
32
+ * The two transport members of an environment (ruling ci-transport-one-home): the operator's member(s) `deploy.remote.sealed` names and
33
+ * the CI member `vps-ci`, two identities by design, neither of which reaches an app container. Only the SSH_GROUP keys may appear in both.
34
+ */
35
+ export function sshTransportMembers(spec) {
36
+ const sealed = spec?.deploy?.remote?.sealed;
37
+ const operator = typeof sealed === "string" ? [sealed] : Object.values(sealed ?? {});
38
+ return new Set([...operator.filter((slug) => typeof slug === "string"), CI_MEMBER]);
39
+ }
31
40
  /**
32
41
  * True when the stack groups `member` on purpose: every key of it is declared in the environment's `secrets:`, per key (`key` with
33
42
  * `member: <member>`) or in the `keys` list of an entry naming the member, or is a key of the sealed SSH member the deploy names.
@@ -1,6 +1,8 @@
1
1
  /** The suffix of a member, and of a legacy one. */
2
2
  export declare const MEMBER_SUFFIX = ".enc.yaml";
3
3
  export declare const LEGACY_SUFFIX = ".enc";
4
+ /** The sealed member that holds the CI transport of an environment: its own identity with a forced command, never the operator's SSH member. */
5
+ export declare const CI_MEMBER = "vps-ci";
4
6
  /** A sealed member's file name (either suffix), as a pattern for a name or the tail of a path. */
5
7
  export declare const SEALED_NAME: RegExp;
6
8
  /** True for the file name of a sealed member, current or legacy. */
@@ -7,6 +7,8 @@ import path from 'node:path';
7
7
  /** The suffix of a member, and of a legacy one. */
8
8
  export const MEMBER_SUFFIX = '.enc.yaml';
9
9
  export const LEGACY_SUFFIX = '.enc';
10
+ /** The sealed member that holds the CI transport of an environment: its own identity with a forced command, never the operator's SSH member. */
11
+ export const CI_MEMBER = 'vps-ci';
10
12
  /** A sealed member's file name (either suffix), as a pattern for a name or the tail of a path. */
11
13
  export const SEALED_NAME = /\.enc(?:\.yaml)?$/u;
12
14
  /** True for the file name of a sealed member, current or legacy. */
@@ -1,3 +1,4 @@
1
+ import { CI_MEMBER } from '../runtime/scripts/lib/sealed-member.mjs';
1
2
  import type { DeployConfig } from './config.mjs';
2
3
  import type { StackContext } from './context.mjs';
3
4
  import type { RemoteArgv } from './ssh-session.mjs';
@@ -74,7 +75,7 @@ export declare const ownerSshRunner: (ctx: StackContext, config: DeployConfig, o
74
75
  /** Opens the runner (`deps.ssh` wins in specs), calls `body(run, notes)` and always cleans the session up. */
75
76
  export declare function withRunner<T>(ctx: StackContext, config: DeployConfig, options: SshOptions, deps: RunnerDeps, body: (run: Runner, notes: string[]) => T | Promise<T>): Promise<T>;
76
77
  /** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
77
- export declare const CI_MEMBER = "vps-ci";
78
+ export { CI_MEMBER };
78
79
  /** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
79
80
  export declare function connectCi(ctx: StackContext, config: DeployConfig, { execute }?: {
80
81
  execute?: boolean;
package/stack/remote.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  // `bash -s` fail with `set: pipefail`). Key material lives in memory and in a private temp directory only; it is never in argv (only the key
10
10
  // FILE path is), in a line, in a result or in an error message.
11
11
  import fs from 'node:fs';
12
- import { isLegacyName, memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
12
+ import { CI_MEMBER, isLegacyName, memberFile } from '../runtime/scripts/lib/sealed-member.mjs';
13
13
  import os from 'node:os';
14
14
  import path from 'node:path';
15
15
  import { tool } from './context.mjs';
@@ -217,7 +217,7 @@ function ciHome(ctx) {
217
217
  return path.join(ctx.env.HOME, '.ssh');
218
218
  }
219
219
  /** The sealed member that holds the CI transport of an environment: VPS_SSH_KEY (the CI-only deploy key), VPS_KNOWN_HOSTS, VPS_HOST and VPS_USER. */
220
- export const CI_MEMBER = 'vps-ci';
220
+ export { CI_MEMBER };
221
221
  /**
222
222
  * The CI transport values: the sealed member `vps-ci` of the environment (opened with the server identity the job holds) when it exists, else the
223
223
  * variables of the job (the transition, and the owner's own runs). The member keeps the host, the account, the key and the pinned host keys out of
@@ -1,5 +1,5 @@
1
1
  /** What an argument is: each kind is a closed alphabet, checked by the client (TypeScript) and by the gate (POSIX sh) alike. */
2
- export type ArgKind = 'base' | 'release' | 'pointer' | 'token' | 'trigger' | 'attempt' | 'seconds';
2
+ export type ArgKind = 'base' | 'release' | 'pointer' | 'token' | 'trigger' | 'attempt' | 'seconds' | 'recipient';
3
3
  /** One named operation: its arguments by kind, whether it reads stdin, and the sh body that runs with the arguments as $1.... */
4
4
  export interface GateOp {
5
5
  readonly name: string;
@@ -20,6 +20,8 @@ export declare function gateOp(name: string): GateOp;
20
20
  export declare function gatedCommand(name: string, args: readonly string[]): string;
21
21
  /** The same operation as one command for a key without the gate: the body under `sh -c` with the arguments as positional parameters. */
22
22
  export declare function inlineCommand(name: string, args: readonly string[]): string;
23
+ /** The command of an operation up to its arguments (`sh -c '<body>' starci-op`): the text a workflow template renders for a key without the gate. */
24
+ export declare function inlinePrefix(name: string): string;
23
25
  /** The POSIX sh text of `starci-ssh-gate`, to install executable at /usr/local/bin/starci-ssh-gate on the manager. */
24
26
  export declare function gateScript(): string;
25
27
  /** True when the CI transport was set up for a key that has the gate (`connect` exports STARCI_SSH_GATE=1 for a sealed `vps-ci` member that says `VPS_GATE: "1"`). */
@@ -10,9 +10,11 @@
10
10
  // A body reads its arguments as $1... ($0 is `starci-op`) and its stdin; it holds no free text.
11
11
  import { refusal } from './errors.mjs';
12
12
  import { shellQuote } from './remote.mjs';
13
+ import { SERVER_AGE_IDENTITY } from './server-key.mjs';
13
14
  const RELEASE_ID = /^[0-9a-f]{16}$/u;
14
15
  const TOKEN = /^[0-9a-f]{32}$/u;
15
16
  const ATTEMPT = /^[0-9]{8}T[0-9]{6}Z-[0-9a-f]{8}$/u;
17
+ const RECIPIENT = /^age1[a-z0-9]{58}$/u;
16
18
  const BASE = /^\/[A-Za-z0-9._/-]+$/u;
17
19
  /** The closed alphabet of each argument kind. */
18
20
  export const ARG_TESTS = Object.freeze({
@@ -23,7 +25,14 @@ export const ARG_TESTS = Object.freeze({
23
25
  trigger: (value) => value === 'ci' || value === 'dispatch' || value === 'urgent',
24
26
  attempt: (value) => ATTEMPT.test(value),
25
27
  seconds: (value) => /^[0-9]{1,6}$/u.test(value),
28
+ recipient: (value) => RECIPIENT.test(value),
26
29
  });
30
+ /**
31
+ * Writes the deploy server's age identity from stdin (stack-key-restore.yml, `--restore github`): the file is the one fixed path SERVER_AGE_IDENTITY,
32
+ * never an argument, it is kept only when its public key is the recipient $1, it is owned by root with mode 600, and an existing identity is never overwritten.
33
+ */
34
+ const INNER_RESTORE = 'PATH="$PATH:/usr/local/bin"; umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"';
35
+ const IDENTITY_RESTORE = `f=${shellQuote(SERVER_AGE_IDENTITY)}; r="$1"; if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c ${shellQuote(INNER_RESTORE)} sh "$f" "$r"`;
27
36
  const OWNER_READ = 'if [ -r "$l/owner" ]; then read -r held_token held_trigger held_stamp < "$l/owner"; else held_token=none; held_trigger=unknown; held_stamp=$(stat -c %Y "$l" 2>/dev/null) || held_stamp=0; fi; case "$held_stamp" in \'\'|*[!0-9]*) held_stamp=0;; esac';
28
37
  /** Prints the release the pointer `$2` under `$1` names, or an empty line for an absent link; a failing readlink fails the script. */
29
38
  const POINTER = 'if [ -L "$1/$2" ]; then basename "$(readlink "$1/$2")"; else printf \'\\n\'; fi';
@@ -32,6 +41,7 @@ const POINTER = 'if [ -L "$1/$2" ]; then basename "$(readlink "$1/$2")"; else pr
32
41
  * rename, a lock unrefreshed for $4 seconds is moved aside (one rename wins) and claimed anew; prints `acquired` or `busy <trigger> <age>`.
33
42
  */
34
43
  export const GATE_OPS = Object.freeze([
44
+ { name: 'identity-restore', args: ['recipient'], stdin: true, body: IDENTITY_RESTORE },
35
45
  { name: 'pointer', args: ['base', 'pointer'], stdin: false, body: POINTER },
36
46
  { name: 'list', args: ['base'], stdin: false, body: `for n in current previous; do if [ -L "$1/$n" ]; then basename "$(readlink "$1/$n")"; else printf '\\n'; fi; done; if [ -d "$1/releases" ]; then ls -1t "$1/releases"; fi` },
37
47
  { name: 'read', args: ['base', 'release'], stdin: false, body: `set -e; s="$1/releases/$2"; for f in stack.yaml release.json; do test -r "$s/$f"; base64 < "$s/$f" | tr -d '\\n'; printf '\\n'; done` },
@@ -80,10 +90,14 @@ export function gatedCommand(name, args) {
80
90
  /** The same operation as one command for a key without the gate: the body under `sh -c` with the arguments as positional parameters. */
81
91
  export function inlineCommand(name, args) {
82
92
  const op = gateOp(name);
83
- return `sh -c ${shellQuote(op.body)} starci-op ${checkedArguments(op, args).map(shellQuote).join(' ')}`;
93
+ return `${inlinePrefix(name)} ${checkedArguments(op, args).map(shellQuote).join(' ')}`;
94
+ }
95
+ /** The command of an operation up to its arguments (`sh -c '<body>' starci-op`): the text a workflow template renders for a key without the gate. */
96
+ export function inlinePrefix(name) {
97
+ return `sh -c ${shellQuote(gateOp(name).body)} starci-op`;
84
98
  }
85
99
  const VALIDATOR = Object.freeze({
86
- base: 'v_base', release: 'v_release', pointer: 'v_pointer', token: 'v_token', trigger: 'v_trigger', attempt: 'v_attempt', seconds: 'v_seconds',
100
+ base: 'v_base', release: 'v_release', pointer: 'v_pointer', token: 'v_token', trigger: 'v_trigger', attempt: 'v_attempt', seconds: 'v_seconds', recipient: 'v_recipient',
87
101
  });
88
102
  /** The sh variable that holds the body of an operation. */
89
103
  const bodyVariable = (name) => `OP_${name.replaceAll('-', '_')}`;
@@ -108,6 +122,7 @@ export function gateScript() {
108
122
  'v_pointer() { case "$1" in current|previous) ;; *) return 1;; esac; }',
109
123
  'v_trigger() { case "$1" in ci|dispatch|urgent) ;; *) return 1;; esac; }',
110
124
  `v_attempt() { case "$1" in ${digits(8)}T${digits(6)}Z-${digits(8, '[0-9a-f]')}) ;; *) return 1;; esac; v_hex 8 "\${1#*Z-}"; }`,
125
+ 'v_recipient() { case "$1" in age1*) ;; *) return 1;; esac; case "$1" in *[!a-z0-9]*) return 1;; esac; [ "${#1}" -eq 62 ]; }',
111
126
  'v_seconds() { case "$1" in \'\'|*[!0-9]*) return 1;; esac; [ "${#1}" -le 6 ]; }',
112
127
  ...bodies,
113
128
  'cmd=${SSH_ORIGINAL_COMMAND-}',
package/sync/index.mjs CHANGED
@@ -42,6 +42,7 @@ import { readTruths } from '../runtime/scripts/lib/loosening-state.mjs';
42
42
  import { parseYaml as bundledParseYaml } from '../runtime/engine/yaml.mjs';
43
43
  import { bootstrapSection } from '../runtime/scripts/hfs/rules/database-constants.mjs';
44
44
  import { SERVER_AGE_IDENTITY } from '../stack/server-key.mjs';
45
+ import { inlinePrefix } from '../stack/ssh-gate.mjs';
45
46
  export const TEMPLATES_DIR = path.join(import.meta.dirname, '..', 'templates');
46
47
  const NODE_MAJOR = 22;
47
48
  const APP_BINARY = 'starci app';
@@ -408,6 +409,8 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
408
409
  nodeMajor: String(NODE_MAJOR),
409
410
  // The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
410
411
  serverAgeIdentity: SERVER_AGE_IDENTITY,
412
+ // The operation `identity-restore` of the ssh gate, as one command for a CI key without the gate (stack/ssh-gate.mjs owns the body), a JSON string so the workflow carries it as one YAML scalar.
413
+ identityRestoreInline: JSON.stringify(inlinePrefix('identity-restore')),
411
414
  sonarKey: sonarKey ?? app.project,
412
415
  sonarHostLine: sonarHostLine(quality.sonar),
413
416
  sonarExclusions: sonarScope(presets).exclusions.join(','),
@@ -3,10 +3,9 @@
3
3
  # stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
4
4
  # identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
5
5
  # owned by root; it never echoes the identity and never overwrites one.
6
- # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
7
- # vars VPS_HOST, VPS_USER (the swarm manager)
8
- # secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
9
- # VPS_KNOWN_HOSTS (pinned host keys)
6
+ # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds the secret
7
+ # SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read). The transport is the sealed member vps-ci of that
8
+ # environment, opened with that identity by the same "stack connect" step the deploy uses: GitHub holds no SSH key, host key, host or account.
10
9
  name: restore stack key
11
10
 
12
11
  on:
@@ -33,26 +32,46 @@ jobs:
33
32
  runs-on: ubuntu-latest
34
33
  environment: ${{ inputs.environment }}
35
34
  timeout-minutes: 10
35
+ env:
36
+ STACK_ENVIRONMENT: ${{ inputs.environment }}
36
37
  steps:
38
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
39
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
40
+ with:
41
+ node-version: 22
42
+ cache: npm
43
+ - run: npm ci --ignore-scripts
44
+ - name: install SOPS
45
+ uses: mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0
46
+ with:
47
+ version: v3.13.2
48
+ - name: connect to the swarm manager
49
+ env:
50
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
51
+ SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
52
+ run: npx --no-install starci app stack connect --env "$STACK_ENVIRONMENT" --execute
37
53
  - name: write the identity on the server
38
54
  env:
39
55
  RECIPIENT: ${{ inputs.recipient }}
40
56
  AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
41
- VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
42
- VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
43
- VPS_HOST: ${{ vars.VPS_HOST }}
44
- VPS_USER: ${{ vars.VPS_USER }}
57
+ INLINE_RESTORE: {{identityRestoreInline}}
45
58
  shell: bash
46
59
  run: |
47
60
  set -euo pipefail
48
61
  [[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
49
62
  [ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
50
63
  umask 077
51
- dir="$(mktemp -d)"
52
- trap 'rm -rf "$dir"' EXIT
53
- printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
- printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
- # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
- remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'PATH="$PATH:/usr/local/bin"; umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
- printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
- -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
64
+ manager="${DOCKER_HOST#ssh://}"
65
+ # The operation is the ssh gate's identity-restore (stack/ssh-gate.mjs): the server writes the identity beside its target, keeps it only
66
+ # when its public key is the recipient, then moves it in. A CI key forced through the gate (STARCI_SSH_GATE=1) names it; a key without the gate runs its body.
67
+ if [ "${STARCI_SSH_GATE:-}" = 1 ]; then
68
+ remote="starci-op identity-restore $RECIPIENT"
69
+ else
70
+ remote="$INLINE_RESTORE $RECIPIENT"
71
+ fi
72
+ printf '%s\n' "$AGE_IDENTITY" | ssh -T "$manager" "$remote"
73
+ - name: disconnect from the swarm manager
74
+ if: ${{ always() }}
75
+ env:
76
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
77
+ run: npx --no-install starci app stack disconnect --env "$STACK_ENVIRONMENT" --execute
@@ -3,10 +3,9 @@
3
3
  # stored it; --restore github dispatches this file). It runs by hand only and writes {{serverAgeIdentity}} on the server when no
4
4
  # identity is there and the secret's public key is the recipient input (deploy.remote.serverRecipient), with umask 077 and mode 600,
5
5
  # owned by root; it never echoes the identity and never overwrites one.
6
- # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds:
7
- # vars VPS_HOST, VPS_USER (the swarm manager)
8
- # secrets SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read), VPS_SSH_KEY (CI-only deploy key),
9
- # VPS_KNOWN_HOSTS (pinned host keys)
6
+ # The environment input names the protected GitHub environment of the deploy (required reviewers or main only), which holds the secret
7
+ # SOPS_AGE_KEY (that environment's server identity, the one its deploy jobs read). The transport is the sealed member vps-ci of that
8
+ # environment, opened with that identity by the same "stack connect" step the deploy uses: GitHub holds no SSH key, host key, host or account.
10
9
  name: restore stack key
11
10
 
12
11
  on:
@@ -33,26 +32,46 @@ jobs:
33
32
  runs-on: ubuntu-latest
34
33
  environment: ${{ inputs.environment }}
35
34
  timeout-minutes: 10
35
+ env:
36
+ STACK_ENVIRONMENT: ${{ inputs.environment }}
36
37
  steps:
38
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
39
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
40
+ with:
41
+ node-version: 22
42
+ cache: npm
43
+ - run: npm ci --ignore-scripts
44
+ - name: install SOPS
45
+ uses: mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0
46
+ with:
47
+ version: v3.13.2
48
+ - name: connect to the swarm manager
49
+ env:
50
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
51
+ SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
52
+ run: npx --no-install starci app stack connect --env "$STACK_ENVIRONMENT" --execute
37
53
  - name: write the identity on the server
38
54
  env:
39
55
  RECIPIENT: ${{ inputs.recipient }}
40
56
  AGE_IDENTITY: ${{ secrets.SOPS_AGE_KEY }}
41
- VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }}
42
- VPS_KNOWN_HOSTS: ${{ secrets.VPS_KNOWN_HOSTS }}
43
- VPS_HOST: ${{ vars.VPS_HOST }}
44
- VPS_USER: ${{ vars.VPS_USER }}
57
+ INLINE_RESTORE: {{identityRestoreInline}}
45
58
  shell: bash
46
59
  run: |
47
60
  set -euo pipefail
48
61
  [[ "$RECIPIENT" =~ ^age1[a-z0-9]+$ ]] || { echo "recipient must be one public age recipient (age1...)" >&2; exit 1; }
49
62
  [ -n "$AGE_IDENTITY" ] || { echo "SOPS_AGE_KEY is not set in this environment" >&2; exit 1; }
50
63
  umask 077
51
- dir="$(mktemp -d)"
52
- trap 'rm -rf "$dir"' EXIT
53
- printf '%s\n' "$VPS_SSH_KEY" > "$dir/id"
54
- printf '%s\n' "$VPS_KNOWN_HOSTS" > "$dir/known_hosts"
55
- # The server writes the identity beside its target, keeps it only when its public key is the recipient, then moves it in.
56
- remote="f={{serverAgeIdentity}}; r=$RECIPIENT; "'if [ "$(id -u)" -eq 0 ]; then S=; else S="sudo -n"; fi; if $S test -e "$f"; then cat >/dev/null; echo "the server already holds $f; move it aside first" >&2; exit 3; fi; $S sh -c '"'"'PATH="$PATH:/usr/local/bin"; umask 077; f="$1"; r="$2"; d="$(dirname "$f")"; mkdir -p "$d"; t="$(mktemp "$d/.restore.XXXXXX")"; cat > "$t"; if [ "$(age-keygen -y "$t" 2>/dev/null)" != "$r" ]; then rm -f "$t"; echo "the secret is not the identity of $r (or age-keygen is missing); nothing was written" >&2; exit 6; fi; chown root:root "$t"; chmod 600 "$t"; mv "$t" "$f"; age-keygen -y "$f"'"'"' sh "$f" "$r"'
57
- printf '%s\n' "$AGE_IDENTITY" | ssh -T -i "$dir/id" -o IdentitiesOnly=yes -o UserKnownHostsFile="$dir/known_hosts" \
58
- -o StrictHostKeyChecking=yes -o BatchMode=yes -o ConnectTimeout=20 "$VPS_USER@$VPS_HOST" "$remote"
64
+ manager="${DOCKER_HOST#ssh://}"
65
+ # The operation is the ssh gate's identity-restore (stack/ssh-gate.mjs): the server writes the identity beside its target, keeps it only
66
+ # when its public key is the recipient, then moves it in. A CI key forced through the gate (STARCI_SSH_GATE=1) names it; a key without the gate runs its body.
67
+ if [ "${STARCI_SSH_GATE:-}" = 1 ]; then
68
+ remote="starci-op identity-restore $RECIPIENT"
69
+ else
70
+ remote="$INLINE_RESTORE $RECIPIENT"
71
+ fi
72
+ printf '%s\n' "$AGE_IDENTITY" | ssh -T "$manager" "$remote"
73
+ - name: disconnect from the swarm manager
74
+ if: ${{ always() }}
75
+ env:
76
+ XDG_DATA_HOME: ${{ runner.temp }}/starci-data
77
+ run: npx --no-install starci app stack disconnect --env "$STACK_ENVIRONMENT" --execute
@@ -167,12 +167,8 @@ ci:
167
167
  secrets:
168
168
  - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
169
169
  - {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
170
- - {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
171
- - {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
172
170
  vars:
173
171
  - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
174
- - {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
175
- - {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
176
172
  services:
177
173
  sonar:
178
174
  provider: sonarqube
@@ -256,12 +256,8 @@ ci:
256
256
  secrets:
257
257
  - {name: SONAR_TOKEN, scope: repository, purpose: Sonar analysis token provisioned for this app}
258
258
  - {name: SOPS_AGE_KEY, scope: environment, environment: production, purpose: Decrypt app members during deployment, optional: true}
259
- - {name: VPS_SSH_KEY, scope: environment, environment: production, purpose: Deployment SSH key, optional: true}
260
- - {name: VPS_KNOWN_HOSTS, scope: environment, environment: production, purpose: Pinned deployment SSH host keys, optional: true}
261
259
  vars:
262
260
  - {name: SONAR_HOST_URL, scope: repository, value: "https://sonar.{{project}}.example.invalid", purpose: Documentation endpoint to replace with this app's public Sonar host}
263
- - {name: VPS_HOST, scope: environment, environment: production, value: 203.0.113.10, purpose: Documentation deployment address to replace before use, optional: true}
264
- - {name: VPS_USER, scope: environment, environment: production, value: deploy, purpose: Documentation deployment user to replace before use, optional: true}
265
261
  services:
266
262
  sonar:
267
263
  provider: sonarqube