@underpostnet/cyberia 3.3.77 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/.env.example +1 -0
  2. package/.github/workflows/coverall.cyberia.ci.yml +12 -3
  3. package/.github/workflows/cyberia-client.cd.yml +2 -1
  4. package/.github/workflows/cyberia-server.cd.yml +2 -1
  5. package/.github/workflows/docker-image.cyberia-client.ci.yml +1 -1
  6. package/.github/workflows/docker-image.cyberia-client.dev.ci.yml +1 -1
  7. package/.github/workflows/docker-image.cyberia-server.ci.yml +1 -1
  8. package/.github/workflows/docker-image.cyberia-server.dev.ci.yml +1 -1
  9. package/.github/workflows/docker-image.engine-cyberia.ci.yml +1 -1
  10. package/.github/workflows/docker-image.engine-cyberia.dev.ci.yml +1 -1
  11. package/.github/workflows/engine-cyberia.ci.yml +6 -0
  12. package/.github/workflows/ghpkg.ci.yml +19 -2
  13. package/.github/workflows/gitlab.ci.yml +8 -6
  14. package/.github/workflows/hardhat.ci.yml +4 -1
  15. package/.github/workflows/npmpkg.ci.yml +9 -5
  16. package/.github/workflows/publish.ci.yml +21 -12
  17. package/.github/workflows/publish.cyberia.ci.yml +25 -14
  18. package/.github/workflows/pwa-microservices-template-page.cd.yml +4 -2
  19. package/CHANGELOG.md +75 -140
  20. package/CLI-HELP.md +28 -1
  21. package/Dockerfile +1 -1
  22. package/Dockerfile.dev +1 -1
  23. package/Dockerfile.test +1 -1
  24. package/README.md +1 -1
  25. package/bin/build.js +2 -4
  26. package/bin/cyberia.js +4 -4
  27. package/bin/index.js +4 -4
  28. package/compose.env +1 -0
  29. package/conf.js +1 -1042
  30. package/deploy/cyberia-client/deploy.sh +13 -11
  31. package/deploy/cyberia-server/deploy.sh +13 -11
  32. package/deploy/dd-cyberia/sync-deploy.sh +7 -28
  33. package/deploy/lib/config.sh +1 -1
  34. package/deploy/lib/github-actions-logging.sh +3 -2
  35. package/deploy/pwa-microservices-template/deploy.sh +7 -1
  36. package/deployment.yaml +1 -1
  37. package/docker-compose.yml +7 -7
  38. package/docs/coverage/cyberia/api/cyberia-instance/cyberia-fallback-capture.js.html +1 -1
  39. package/docs/coverage/cyberia/api/cyberia-instance/index.html +1 -1
  40. package/docs/coverage/cyberia/api/cyberia-server-defaults/cyberia-server-defaults.js.html +1 -1
  41. package/docs/coverage/cyberia/api/cyberia-server-defaults/index.html +1 -1
  42. package/docs/coverage/cyberia/api/object-layer/index.html +1 -1
  43. package/docs/coverage/cyberia/api/object-layer/object-layer.model.js.html +1 -1
  44. package/docs/coverage/cyberia/index.html +1 -1
  45. package/docs/coverage/cyberia/projects/cyberia/atlas-sprite-sheet-generator.js.html +1 -1
  46. package/docs/coverage/cyberia/projects/cyberia/index.html +1 -1
  47. package/docs/coverage/cyberia/projects/cyberia/instance-backup.js.html +1 -1
  48. package/docs/coverage/cyberia/projects/cyberia/instance-data.js.html +1 -1
  49. package/docs/coverage/cyberia/projects/cyberia/shape-generator.js.html +1 -1
  50. package/docs/coverage/cyberia/projects/cyberia/stat-balance.js.html +1 -1
  51. package/hardhat/package-lock.json +6 -6
  52. package/hardhat/package.json +2 -2
  53. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +1 -1
  54. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +1 -1
  55. package/manifests/deployment/dd-cyberia-development/deployment.yaml +1 -1
  56. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  57. package/package.json +19 -17
  58. package/scripts/test-monitor.sh +1 -1
  59. package/src/api/file/file.router.js +7 -1
  60. package/src/cli/baremetal.js +1 -1
  61. package/src/cli/docker-compose.js +1 -1
  62. package/src/cli/index.js +36 -0
  63. package/src/cli/kubectl.js +4 -3
  64. package/src/cli/release.js +2 -0
  65. package/src/cli/repository.js +7 -2
  66. package/src/cli/run.js +1 -1
  67. package/src/cli/secrets.js +393 -200
  68. package/src/cli/socketsecurity.js +260 -0
  69. package/src/client/components/core/CssCore.js +3 -0
  70. package/src/client/components/core/Modal.js +160 -36
  71. package/src/client/components/core/SearchBox.js +0 -1
  72. package/src/client/components/underpost/AppShellUnderpost.js +13 -1
  73. package/src/client/components/underpost/CssUnderpost.js +15 -2
  74. package/src/client/public/cyberia-docs/CYBERIA-CLIENT.md +8 -0
  75. package/src/client/public/cyberia-docs/CYBERIA.md +1 -1
  76. package/src/client/public/cyberia-docs/OFF-CHAIN-ECONOMY.md +54 -56
  77. package/src/client/public/cyberia-docs/ROADMAP.md +1 -1
  78. package/src/client/public/cyberia-docs/UNDERPOST-PLATFORM.md +30 -29
  79. package/src/client/public/cyberia-docs/WHITE-PAPER.md +1 -1
  80. package/src/client/ssr/body/UnderpostDefaultSplashScreen.js +54 -25
  81. package/src/client/ssr/head/Css.js +12 -10
  82. package/src/client/ssr/views/Cyberia404.js +6 -10
  83. package/src/client/ssr/views/NotFound.js +75 -0
  84. package/src/client-builder/client-bundle.js +2 -2
  85. package/src/index.js +13 -1
  86. package/src/runtime/engine-cyberia/Dockerfile +1 -1
  87. package/src/runtime/engine-cyberia/Dockerfile.dev +1 -1
  88. package/src/runtime/engine-cyberia/Dockerfile.test +1 -1
  89. package/src/runtime/engine-cyberia/compose.env +1 -0
  90. package/src/runtime/engine-cyberia/docker-compose.yml +7 -7
  91. package/src/server/build/catalog.js +1 -0
  92. package/src/server/build/execution.js +19 -5
  93. package/src/server/build/package.js +166 -3
  94. package/src/server/build/testing.js +2 -1
  95. package/src/server/runtime/conf.js +21 -0
  96. package/src/server/runtime/config-scope.js +2 -0
  97. package/src/server/runtime/process.js +4 -3
  98. package/src/server/security/socketsecurity.js +1735 -0
  99. package/test/integration/infra/1-security/config-scope.test.js +3 -0
  100. package/test/integration/infra/1-security/socketsecurity.test.js +1041 -0
  101. package/test/integration/infra/1-security/sops-secret-store.test.js +201 -71
  102. package/test/unit/build-template.test.js +24 -0
  103. package/test/unit/deploy-log-table.test.js +38 -0
  104. package/test/unit/execution-profiles.test.js +1 -0
  105. package/test/unit/package.test.js +118 -0
@@ -27,14 +27,23 @@ const SOPS_ENCRYPTED_REGEX = '^(data|stringData)$';
27
27
  const SOPS_VERSION = 'v3.10.2';
28
28
  const AGE_VERSION = 'v1.2.1';
29
29
 
30
- // The GitHub-side credential CI workflows and cross-repository checkouts authenticate with. The
31
- // stored manifest keeps this file's kebab-case naming, while the data key keeps the environment
32
- // spelling so a workload consumes it through `envFrom` unchanged. Deliberately absent from
33
- // MANAGED_SECRETS: its authoritative home is the GitHub Actions secret store, and the encrypted
34
- // manifest is an optional mirror rather than the origin.
30
+ // GitHub Actions secrets this domain distributes to a deploy's repositories. `GIT_AUTH_TOKEN` is
31
+ // the one with a mirror in the encrypted store: the stored manifest keeps this file's kebab-case
32
+ // naming, while the data key keeps the environment spelling so a workload consumes it through
33
+ // `envFrom` unchanged. Deliberately absent from MANAGED_SECRETS: its authoritative home is the
34
+ // GitHub Actions secret store, and the encrypted manifest is an optional mirror rather than the origin.
35
35
  const GIT_AUTH_TOKEN_KEY = 'GIT_AUTH_TOKEN';
36
36
  const GIT_AUTH_TOKEN_SECRET = 'git-auth-token';
37
- const GIT_AUTH_TOKEN_STAGE_DIR = '/dev/shm/underpost-git-auth';
37
+ const ACTIONS_SECRET_STAGE_DIR = '/dev/shm/underpost-actions-secret';
38
+ // Where the writes go: the account type, its owner, and the credential `gh` runs under.
39
+ const GITHUB_TARGET_ENV = Object.freeze({
40
+ type: 'GITHUB_TARGET_TYPE',
41
+ user: 'GITHUB_USERNAME',
42
+ org: 'GITHUB_ORG_NAME',
43
+ token: 'GITHUB_SECRET_TOKEN',
44
+ });
45
+ const GITHUB_TARGET_TYPES = ['user', 'org'];
46
+ const GITHUB_SECRET_LEVELS = ['repo', 'org'];
38
47
 
39
48
  // The second meta id alongside `dd`. The template lineage is not a deploy, so it carries no conf
40
49
  // id and resolves to its own repositories rather than through the `engine-<conf-id>` naming.
@@ -251,6 +260,22 @@ const stdinIsRedirected = () => {
251
260
 
252
261
  const looksLikeGitHubToken = (token) => /^(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})$/.test(token);
253
262
 
263
+ // Per-key rules on top of the generic write. A token is checked for shape; any other value is
264
+ // opaque and taken as given.
265
+ const ACTIONS_SECRET_POLICIES = Object.freeze({
266
+ [GIT_AUTH_TOKEN_KEY]: {
267
+ validate(value) {
268
+ if (/\s/.test(value))
269
+ throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} contains whitespace; it is not a token`);
270
+ if (!looksLikeGitHubToken(value))
271
+ logger.warn(
272
+ `The replacement ${GIT_AUTH_TOKEN_KEY} does not match a known GitHub token shape ` +
273
+ `(ghp_…, gho_…, github_pat_…). Continuing — GitHub token formats have changed before.`,
274
+ );
275
+ },
276
+ },
277
+ });
278
+
254
279
  /**
255
280
  * @class UnderpostSecret
256
281
  * @description Manages the secrets of the application.
@@ -368,45 +393,42 @@ class UnderpostSecret {
368
393
  * Replaces the current projection: either the Age identity the store is sealed to, or the
369
394
  * value of a credential itself.
370
395
  *
371
- * `--args secret=GIT_AUTH_TOKEN` selects the credential rotation — the GitHub Actions secret
372
- * every repository of a deploy authenticates with, mirrored into the encrypted store.
373
- * Without it the recipient rotation runs, which re-keys stored manifests onto a new Age
374
- * recipient: secret values are unchanged there, so no workload restart is needed.
396
+ * `--args secret=<NAME|NAME>` selects the credential rotation — GitHub Actions secrets on
397
+ * every repository of a deploy, under a user account or an organization, with `GIT_AUTH_TOKEN`
398
+ * mirrored into the encrypted store. Without it the recipient rotation runs, which re-keys
399
+ * stored manifests onto a new Age recipient: secret values are unchanged there, so no
400
+ * workload restart is needed.
375
401
  * @param {object} context - Normalized domain context. Requires either
376
- * `--args secret=GIT_AUTH_TOKEN` (with `token=`, `deploy-id=`, `owner=`, `repos=`,
377
- * `store=true`, `apply=true`) or `--args recipient=age1...` (with `prune=true` to revoke
378
- * previous recipients and `keep=` to retain named ones).
402
+ * `--args secret=<NAME|NAME>` (with `value=`, `source=host`, `deploy-id=`, `target=`,
403
+ * `owner=`, `level=`, `repos=`, `store=true`, `apply=true`) or `--args recipient=age1...`
404
+ * (with `prune=true` to revoke previous recipients and `keep=` to retain named ones).
379
405
  * @returns {*} The rotation result.
380
406
  * @memberof UnderpostSecret
381
407
  */
382
408
  rotate(context = {}) {
383
409
  context = domainContextFactory(context);
384
- const secret = `${context.args.secret ?? ''}`.trim();
385
- if (secret) {
386
- if (secret.toUpperCase() !== GIT_AUTH_TOKEN_KEY)
387
- // Phrased without a `secret=<value>` pair on purpose: the log redactor treats one as a
388
- // credential and would replace the very name the operator needs to read back.
389
- throw new Error(
390
- `[secret] rotate does not know the credential '${secret}'; ${GIT_AUTH_TOKEN_KEY} is the only one ` +
391
- `it rotates. Re-key the store's encryption identity with --args recipient=age1... instead.`,
392
- );
393
- return Underpost.secret.rotateGitAuthToken({
410
+ const keys = `${context.args.secret ?? ''}`.trim();
411
+ if (keys)
412
+ return Underpost.secret.rotateActionsSecrets({
413
+ keys,
394
414
  namespace: context.namespace,
415
+ env: context.env,
395
416
  dryRun: context.dryRun,
396
- token: context.args.token,
417
+ value: context.args.value,
418
+ source: context.args.source,
397
419
  deployId: context.args['deploy-id'] ?? context.args.deployId,
420
+ target: context.args.target,
398
421
  owner: context.args.owner,
422
+ level: context.args.level,
399
423
  repos: context.args.repos,
400
424
  store: context.args.store,
401
425
  apply: context.args.apply,
402
426
  });
403
- }
404
427
  const recipient = `${context.args.recipient ?? ''}`.trim();
405
428
  if (!recipient)
406
429
  throw new Error(
407
430
  '[secret] rotate requires a target: --args recipient=<age-public-key> re-keys the store onto a new ' +
408
- `Age identity, and the 'secret' parameter rotates a credential value (${GIT_AUTH_TOKEN_KEY} is the ` +
409
- 'one it supports).',
431
+ 'Age identity, and --args secret=<NAME|NAME> writes GitHub Actions secrets.',
410
432
  );
411
433
  return Underpost.secret.rotateRecipient(recipient, {
412
434
  namespace: context.namespace,
@@ -1641,10 +1663,69 @@ UNDERPOST_SOPS_ENV_EOF`,
1641
1663
  },
1642
1664
 
1643
1665
  /**
1644
- * @method gitAuthTokenTargets
1645
- * @description Resolves every GitHub repository that carries a deploy's `GIT_AUTH_TOKEN`:
1646
- * the private configuration repository its conf lives in, and the engine source repositories
1647
- * it deploys from — production and test, which are one deploy under two names.
1666
+ * @method githubTargetFactory
1667
+ * @description Resolves where GitHub Actions secrets are written: the account type, the
1668
+ * owner every derived repository name resolves under, the level the writes land at, and the
1669
+ * credential `gh` runs under.
1670
+ *
1671
+ * `GITHUB_TARGET_TYPE` selects `user` or `org`, and the owner follows it: `GITHUB_USERNAME`
1672
+ * (after the owner of `ENGINE_SRC_REPO`, so a node keeps addressing the fleet it was
1673
+ * provisioned from) or `GITHUB_ORG_NAME`. An explicit `owner` overrides both.
1674
+ * `GITHUB_SECRET_TOKEN` is the PAT the writes authenticate with — `repo` scope, plus
1675
+ * `admin:org` for organization secrets; unset, `gh` uses the account `gh auth login` stored.
1676
+ *
1677
+ * `level` is where a value lands. `repo` writes each repository's own secret and works for
1678
+ * both account types. `org` writes one organization secret visible to the resolved
1679
+ * repositories; it needs an organization, and a free organization cannot share it with its
1680
+ * private repositories.
1681
+ * @param {object} [options={}] - Resolution options.
1682
+ * @param {string} [options.target] - `user` or `org`; `GITHUB_TARGET_TYPE` otherwise.
1683
+ * @param {string} [options.owner] - Explicit owner.
1684
+ * @param {string} [options.level] - `repo` or `org`; `repo` otherwise.
1685
+ * @returns {{type: string, owner: string, level: string, token: string}} The target.
1686
+ * @memberof UnderpostSecret
1687
+ */
1688
+ githubTargetFactory(options = {}) {
1689
+ const read = (key) => `${process.env[key] ?? ''}`.trim();
1690
+ const type = (`${options.target ?? ''}`.trim() || read(GITHUB_TARGET_ENV.type) || 'user').toLowerCase();
1691
+ if (!GITHUB_TARGET_TYPES.includes(type))
1692
+ throw new Error(`[secret] unknown GitHub target type '${type}'; expected ${GITHUB_TARGET_TYPES.join(' or ')}`);
1693
+ const level = (`${options.level ?? ''}`.trim() || 'repo').toLowerCase();
1694
+ if (!GITHUB_SECRET_LEVELS.includes(level))
1695
+ throw new Error(`[secret] unknown secret level '${level}'; expected ${GITHUB_SECRET_LEVELS.join(' or ')}`);
1696
+ if (level === 'org' && type !== 'org')
1697
+ throw new Error(`[secret] an organization secret needs ${GITHUB_TARGET_ENV.type}=org or --args target=org`);
1698
+ const envSource = read('ENGINE_SRC_REPO');
1699
+ const owner =
1700
+ `${options.owner ?? ''}`.trim() ||
1701
+ (type === 'org'
1702
+ ? read(GITHUB_TARGET_ENV.org)
1703
+ : (envSource.includes('/') ? envSource.split('/')[0] : '') || read(GITHUB_TARGET_ENV.user) || 'underpostnet');
1704
+ if (!owner)
1705
+ throw new Error(
1706
+ `[secret] ${GITHUB_TARGET_ENV.type}=org needs ${GITHUB_TARGET_ENV.org}, or --args owner=<organization>`,
1707
+ );
1708
+ return { type, owner, level, token: read(GITHUB_TARGET_ENV.token) };
1709
+ },
1710
+
1711
+ /**
1712
+ * @method ghEnvFactory
1713
+ * @description The environment `gh` runs under. `GITHUB_SECRET_TOKEN` travels as `GH_TOKEN`,
1714
+ * so the writes authenticate as the credential chosen for them rather than as whatever token
1715
+ * the shell exports.
1716
+ * @param {{token?: string}} [target={}] - Resolved target.
1717
+ * @returns {NodeJS.ProcessEnv} Child environment.
1718
+ * @memberof UnderpostSecret
1719
+ */
1720
+ ghEnvFactory(target = {}) {
1721
+ return target.token ? { ...process.env, GH_TOKEN: target.token } : process.env;
1722
+ },
1723
+
1724
+ /**
1725
+ * @method actionsSecretTargets
1726
+ * @description Resolves every GitHub repository that carries a deploy's Actions secrets:
1727
+ * the private configuration repository its conf lives in, and the engine source
1728
+ * repositories it deploys from — production and test, which are one deploy under two names.
1648
1729
  *
1649
1730
  * Naming is delegated to {@link UnderpostRepository} rather than re-derived here, so a
1650
1731
  * rotation targets exactly the repositories `run pull` and `deploy/lib/host.sh` resolve for
@@ -1655,32 +1736,34 @@ UNDERPOST_SOPS_ENV_EOF`,
1655
1736
  * Each deploy contributes its private conf repository, its production and test engine sources,
1656
1737
  * its `engine-ghpkg-<conf-id>` package mirror, and every `metadata.repository` its
1657
1738
  * `conf.instances.json` declares — an instance is a separate product with its own workflows
1658
- * reading the same token. Derived names that do not exist are dropped by the reachability
1739
+ * reading the same secrets. Derived names that do not exist are dropped by the reachability
1659
1740
  * probe, so no separate existence check is needed here.
1660
1741
  *
1742
+ * Every name resolves under the owner {@link githubTargetFactory} chose. Under an
1743
+ * organization target a declared slug — an instance repository, `ENGINE_SRC_REPO` — is
1744
+ * re-owned too: the organization mirrors carry the same repository names, and a write that
1745
+ * followed the declared owner would land on the account being migrated away from.
1746
+ *
1661
1747
  * `dd` fans out across `engine-private/deploy/dd.routes`, so one rotation covers the whole
1662
- * fleet. `template` is the second meta id: the template lineage carries no conf id, so it
1663
- * resolves to `pwa-microservices-template`, its `-ghpkg` mirror, and `engine`. The union is
1664
- * deduplicated: deploys share repositories, and one listed twice would be rotated twice.
1748
+ * fleet. `template` is the second meta id, usable alone or in a list: the template lineage
1749
+ * carries no conf id, so it resolves to `pwa-microservices-template`, its `-ghpkg` mirror, and
1750
+ * `engine`. The union is deduplicated: deploys share repositories, and one listed twice would
1751
+ * be written twice.
1665
1752
  * @param {object} [options={}] - Resolution options.
1666
1753
  * @param {string} [options.deployId] - Deploy id, any repository reference naming it, a list
1667
1754
  * separated by `|`, `;` or whitespace, the meta id `dd` for every deploy in
1668
1755
  * `engine-private/deploy/dd.routes`, or `template` for the template lineage. Falls back to
1669
1756
  * `ENGINE_SRC_REPO`, then the monorepo pair.
1670
- * @param {string} [options.owner] - GitHub owner. Falls back to the owner of `ENGINE_SRC_REPO`,
1671
- * then `GITHUB_USERNAME`, then `underpostnet`.
1757
+ * @param {string} [options.owner] - GitHub owner; see {@link githubTargetFactory}.
1758
+ * @param {string} [options.target] - Account type; see {@link githubTargetFactory}.
1672
1759
  * @param {string} [options.repos] - Extra targets separated by `|`, `;` or whitespace —
1673
- * `--args` itself splits on commas, so a list there cannot use one.
1760
+ * `--args` itself splits on commas, so a list there cannot use one. Taken as written.
1674
1761
  * @returns {Array<string>} Deduplicated `owner/repo` slugs, private configuration first.
1675
1762
  * @memberof UnderpostSecret
1676
1763
  */
1677
- gitAuthTokenTargets(options = {}) {
1764
+ actionsSecretTargets(options = {}) {
1765
+ const { type, owner } = Underpost.secret.githubTargetFactory(options);
1678
1766
  const envSource = `${process.env.ENGINE_SRC_REPO ?? ''}`.trim();
1679
- const owner =
1680
- `${options.owner ?? ''}`.trim() ||
1681
- (envSource.includes('/') ? envSource.split('/')[0] : '') ||
1682
- process.env.GITHUB_USERNAME ||
1683
- 'underpostnet';
1684
1767
  const requested = `${options.deployId ?? ''}`.trim() || envSource;
1685
1768
  // `dd` is the meta id every runner reads as "all of dd.routes", resolved through the one
1686
1769
  // reader the cluster deploys from — a rotation that parsed the route table itself could
@@ -1696,31 +1779,34 @@ UNDERPOST_SOPS_ENV_EOF`,
1696
1779
  `rather than the fleet. Check out engine-private, or name the deploys explicitly with ` +
1697
1780
  `--args "deploy-id=dd-one|dd-two".`,
1698
1781
  );
1782
+ const reowned = (slug) => (type === 'org' && slug ? `${owner}/${slug.split('/').pop()}` : slug);
1699
1783
  const candidates = [];
1700
1784
  // Unioned and deduplicated: deploys share repositories (every one of them pairs with the
1701
1785
  // same engine-private when ENGINE_SRC_PRIVATE_REPO names it), and a repository listed twice
1702
- // would be rotated twice.
1703
- if (requested === TEMPLATE_ALIAS) candidates.push(...TEMPLATE_REPOS.map((repo) => `${owner}/${repo}`));
1704
- else
1705
- for (const reference of references.length > 0 ? references : ['']) {
1706
- const confId = Underpost.repo.confIdFactory(reference);
1707
- const source = `${owner}/${Underpost.repo.engineRepoFactory(confId)}`;
1708
- const ghpkg = Underpost.repo.ghpkgRepoFactory(confId);
1709
- candidates.push(
1710
- // Paired off the source rather than named on its own, so the conf repository and the
1711
- // engine it configures can never be resolved apart.
1712
- Underpost.repo.enginePairFactory({ engine: source, account: owner }).enginePrivate,
1713
- source,
1714
- `${owner}/${Underpost.repo.engineRepoFactory(confId, { test: true })}`,
1715
- // The ghpkg mirror and the instance repositories run their own workflows against the
1716
- // same token, so a rotation that skipped them would leave half the deploy behind.
1717
- ...(ghpkg ? [`${owner}/${ghpkg}`] : []),
1718
- ...(confId ? Underpost.repo.instanceRepos(`dd-${confId}`) : []),
1719
- );
1786
+ // would be written twice.
1787
+ for (const reference of references.length > 0 ? references : ['']) {
1788
+ if (reference === TEMPLATE_ALIAS) {
1789
+ candidates.push(...TEMPLATE_REPOS.map((repo) => `${owner}/${repo}`));
1790
+ continue;
1720
1791
  }
1792
+ const confId = Underpost.repo.confIdFactory(reference);
1793
+ const source = `${owner}/${Underpost.repo.engineRepoFactory(confId)}`;
1794
+ const ghpkg = Underpost.repo.ghpkgRepoFactory(confId);
1795
+ candidates.push(
1796
+ // Paired off the source rather than named on its own, so the conf repository and the
1797
+ // engine it configures can never be resolved apart.
1798
+ Underpost.repo.enginePairFactory({ engine: source, account: owner }).enginePrivate,
1799
+ source,
1800
+ `${owner}/${Underpost.repo.engineRepoFactory(confId, { test: true })}`,
1801
+ // The ghpkg mirror and the instance repositories run their own workflows against the
1802
+ // same secrets, so a rotation that skipped them would leave half the deploy behind.
1803
+ ...(ghpkg ? [`${owner}/${ghpkg}`] : []),
1804
+ ...(confId ? Underpost.repo.instanceRepos(`dd-${confId}`).map(reowned) : []),
1805
+ );
1806
+ }
1721
1807
  candidates.push(
1722
- `${process.env.ENGINE_SRC_PRIVATE_REPO ?? ''}`.trim(),
1723
- envSource,
1808
+ reowned(`${process.env.ENGINE_SRC_PRIVATE_REPO ?? ''}`.trim()),
1809
+ reowned(envSource),
1724
1810
  ...`${options.repos ?? ''}`.split(/[,|;\s]+/),
1725
1811
  );
1726
1812
  const targets = [];
@@ -1739,34 +1825,47 @@ UNDERPOST_SOPS_ENV_EOF`,
1739
1825
  },
1740
1826
 
1741
1827
  /**
1742
- * @method plannedTokenSource
1743
- * @description Names the source a rotation would take its token from, without reading,
1744
- * minting or prompting for anything. Pure, so `--dry-run` can report the plan truthfully.
1828
+ * @method plannedSecretSource
1829
+ * @description Names the source a key's value would come from, without reading, prompting
1830
+ * for or consuming anything. Pure, so `--dry-run` can report the plan truthfully.
1831
+ *
1832
+ * Sources, in order: `--args value=`, piped stdin, the key's own environment variable, the
1833
+ * host configuration (`engine-private/deploy/scopes/*.env.<env>`), then a no-echo terminal
1834
+ * prompt. `source=host` pins the host configuration, so automation cannot take a stale
1835
+ * export by mistake.
1836
+ * @param {string} key - Secret name.
1745
1837
  * @param {object} [options={}] - Rotation options.
1746
- * @param {string} [options.token] - Token supplied through `--args token=`.
1838
+ * @param {string} [options.value] - Value supplied through `--args value=`.
1839
+ * @param {string} [options.source] - `host` to read the host configuration only.
1840
+ * @param {Object<string, string>} [hostValues={}] - The host configuration.
1747
1841
  * @returns {string} Human-readable source name.
1748
1842
  * @memberof UnderpostSecret
1749
1843
  */
1750
- plannedTokenSource(options = {}) {
1751
- if (`${options.token ?? ''}`.trim()) return '--args token';
1844
+ plannedSecretSource(key, options = {}, hostValues = {}) {
1845
+ const hostValue = `${hostValues[key] ?? ''}`.trim();
1846
+ if (options.source === 'host')
1847
+ return hostValue ? 'host configuration' : '(unavailable: not in the host configuration)';
1848
+ if (`${options.value ?? ''}`.trim()) return '--args value';
1752
1849
  if (stdinIsRedirected()) return 'piped stdin';
1753
- if (`${process.env[GIT_AUTH_TOKEN_KEY] ?? ''}`.trim()) return `${GIT_AUTH_TOKEN_KEY} environment`;
1754
- return process.stdin.isTTY ? 'interactive prompt' : '(unavailable: no token, nothing piped, no terminal)';
1850
+ if (`${process.env[key] ?? ''}`.trim()) return `${key} environment`;
1851
+ if (hostValue) return 'host configuration';
1852
+ return process.stdin.isTTY ? 'interactive prompt' : '(unavailable: no value, nothing piped, no terminal)';
1755
1853
  },
1756
1854
 
1757
1855
  /**
1758
- * @method probeGitAuthTokenTargets
1759
- * @description Splits resolved targets into those the current `gh` credential can actually
1760
- * reach and those it cannot, without writing anything.
1856
+ * @method probeActionsSecretTargets
1857
+ * @description Splits resolved targets into those the `gh` credential can actually reach
1858
+ * and those it cannot, without writing anything.
1761
1859
  *
1762
1860
  * A deploy does not necessarily own every repository its naming implies — a test source repo
1763
1861
  * often does not exist — so this is what keeps a fleet fan-out from aborting on the first
1764
1862
  * absent one, and what lets `--dry-run` report the real target set rather than the derived one.
1765
1863
  * @param {Array<string>} targets - `owner/repo` slugs.
1864
+ * @param {{token?: string}} [target={}] - Resolved target, for the credential.
1766
1865
  * @returns {{reachable: Array<string>, unreachable: Array<string>}} The split.
1767
1866
  * @memberof UnderpostSecret
1768
1867
  */
1769
- probeGitAuthTokenTargets(targets = []) {
1868
+ probeActionsSecretTargets(targets = [], target = {}) {
1770
1869
  const reachable = [];
1771
1870
  const unreachable = [];
1772
1871
  for (const repo of targets) {
@@ -1775,6 +1874,7 @@ UNDERPOST_SOPS_ENV_EOF`,
1775
1874
  silent: true,
1776
1875
  silentOnError: true,
1777
1876
  disableLog: true,
1877
+ env: Underpost.secret.ghEnvFactory(target),
1778
1878
  });
1779
1879
  if (`${view}`.trim()) reachable.push(repo);
1780
1880
  else {
@@ -1786,53 +1886,65 @@ UNDERPOST_SOPS_ENV_EOF`,
1786
1886
  },
1787
1887
 
1788
1888
  /**
1789
- * @method stageGitAuthToken
1790
- * @description Materializes the replacement token onto tmpfs at mode 600 — the single source
1791
- * both the GitHub write and the manifest write read from.
1889
+ * @method stageActionsSecret
1890
+ * @description Materializes one value onto tmpfs at mode 600 — the single source the GitHub
1891
+ * write and, for `GIT_AUTH_TOKEN`, the manifest write read from.
1792
1892
  *
1793
1893
  * The value never travels as a command argument: `gh secret set` takes it on stdin and the
1794
1894
  * manifest is built by Node, so it reaches neither the process table nor the command log. An
1795
1895
  * interactive prompt writes straight into the staged file for the same reason — captured
1796
1896
  * stdout is logged, a file is not.
1797
1897
  *
1798
- * Sources, in order: `--args token=`, piped stdin, the `GIT_AUTH_TOKEN` environment, then a
1799
- * no-echo terminal prompt. Piping is the one that keeps a token out of both the process table
1800
- * and the shell history, so it is what automation should use.
1801
- * @param {string} stagePath - tmpfs path to write the token to.
1802
- * @param {object} [options={}] - Token sources.
1803
- * @param {string} [options.token] - The token itself, from `--args token=`.
1804
- * @returns {{token: string, source: string}} The staged token and where it came from.
1898
+ * Sources are the ones {@link plannedSecretSource} names, in that order. Piping is the one
1899
+ * that keeps a value out of both the process table and the shell history; the host
1900
+ * configuration is the one automation reads, because it is the durable record.
1901
+ * @param {string} stagePath - tmpfs path to write the value to.
1902
+ * @param {string} key - Secret name.
1903
+ * @param {object} [options={}] - Value sources; see {@link plannedSecretSource}.
1904
+ * @param {Object<string, string>} [hostValues={}] - The host configuration.
1905
+ * @returns {{value: string, source: string}} The staged value and where it came from.
1805
1906
  * @memberof UnderpostSecret
1806
1907
  */
1807
- stageGitAuthToken(stagePath, options = {}) {
1908
+ stageActionsSecret(stagePath, key, options = {}, hostValues = {}) {
1808
1909
  // `GITHUB_TOKEN` is deliberately not a source: it is the credential `gh` authenticates
1809
1910
  // *with*, which during a rotation is the outgoing token. Reading it here would re-set the
1810
1911
  // value being replaced and report a rotation that never happened.
1811
- const provided = `${options.token ?? ''}`.trim();
1812
- const inherited = `${process.env[GIT_AUTH_TOKEN_KEY] ?? ''}`.trim();
1912
+ const provided = `${options.value ?? ''}`.trim();
1913
+ const inherited = `${process.env[key] ?? ''}`.trim();
1914
+ const hostValue = `${hostValues[key] ?? ''}`.trim();
1813
1915
  let source;
1814
- if (provided) {
1916
+ if (options.source === 'host') {
1917
+ if (!hostValue)
1918
+ throw new Error(
1919
+ `[secret] ${key} is not in the host configuration (${Underpost.host.sourceLabel(options.env)})`,
1920
+ );
1921
+ writeStageFileSync(stagePath, hostValue);
1922
+ source = 'host configuration';
1923
+ } else if (provided) {
1815
1924
  writeStageFileSync(stagePath, provided);
1816
- source = '--args token';
1925
+ source = '--args value';
1817
1926
  } else if (stdinIsRedirected()) {
1818
- // Ahead of the environment: a pipe is what the operator chose for this run, while
1819
- // GIT_AUTH_TOKEN may be an inherited export still holding the outgoing token.
1927
+ // Ahead of the environment: a pipe is what the operator chose for this run, while the
1928
+ // environment may be an inherited export still holding the outgoing value.
1820
1929
  writeStageFileSync(stagePath, fs.readFileSync(0, 'utf8'));
1821
1930
  source = 'piped stdin';
1822
1931
  } else if (inherited) {
1823
1932
  writeStageFileSync(stagePath, inherited);
1824
- source = `${GIT_AUTH_TOKEN_KEY} environment`;
1933
+ source = `${key} environment`;
1934
+ } else if (hostValue) {
1935
+ writeStageFileSync(stagePath, hostValue);
1936
+ source = 'host configuration';
1825
1937
  } else {
1826
1938
  if (!process.stdin.isTTY)
1827
1939
  throw new Error(
1828
- `[secret] rotate needs the replacement token: pipe it in ` +
1829
- `(printf %s "$TOKEN" | node bin secret rotate …), pass --args token=<token>, export ` +
1830
- `${GIT_AUTH_TOKEN_KEY}, or run this from a terminal to be prompted.`,
1940
+ `[secret] rotate needs a value for ${key}: pipe it in ` +
1941
+ `(printf %s "$VALUE" | node bin secret rotate …), pass --args value=<value>, export ${key}, ` +
1942
+ `declare it in the host configuration with --args source=host, or run this from a terminal to be prompted.`,
1831
1943
  );
1832
1944
  // Created empty first so the file exists at mode 600 before anything is read into it.
1833
1945
  writeStageFileSync(stagePath, '');
1834
1946
  shellExec(
1835
- `bash -c 'set -o pipefail; umask 077; read -rsp "New ${GIT_AUTH_TOKEN_KEY}: " value </dev/tty; ` +
1947
+ `bash -c 'set -o pipefail; umask 077; read -rsp "New ${key}: " value </dev/tty; ` +
1836
1948
  `echo >/dev/tty; printf %s "$value" > "${stagePath}"'`,
1837
1949
  { disableLog: true },
1838
1950
  );
@@ -1840,17 +1952,56 @@ UNDERPOST_SOPS_ENV_EOF`,
1840
1952
  }
1841
1953
 
1842
1954
  const raw = fs.readFileSync(stagePath, 'utf8');
1843
- const token = raw.replace(/\r?\n$/, '');
1844
- if (!token) throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} is empty`);
1845
- if (/\s/.test(token))
1846
- throw new Error(`[secret] the replacement ${GIT_AUTH_TOKEN_KEY} contains whitespace; it is not a token`);
1847
- if (token !== raw) writeStageFileSync(stagePath, token);
1848
- if (!looksLikeGitHubToken(token))
1849
- logger.warn(
1850
- `The replacement ${GIT_AUTH_TOKEN_KEY} does not match a known GitHub token shape ` +
1851
- `(ghp_…, gho_…, github_pat_…). Continuing — GitHub token formats have changed before.`,
1955
+ const value = raw.replace(/\r?\n$/, '');
1956
+ if (!value) throw new Error(`[secret] the value for ${key} is empty`);
1957
+ if (value !== raw) writeStageFileSync(stagePath, value);
1958
+ ACTIONS_SECRET_POLICIES[key]?.validate?.(value);
1959
+ return { value, source };
1960
+ },
1961
+
1962
+ /**
1963
+ * @method writeActionsSecret
1964
+ * @description Writes one staged value to GitHub. At the `repo` level every repository gets
1965
+ * its own secret. At the `org` level the organization's repositories share one organization
1966
+ * secret whose visibility is exactly that set, and a repository under another owner still
1967
+ * gets its own — an instance declared outside the organization is not left behind.
1968
+ *
1969
+ * The value arrives on stdin from the staged file, never as an argument, and the command is
1970
+ * kept out of the log.
1971
+ * @param {object} params - Write parameters.
1972
+ * @param {string} params.key - Secret name.
1973
+ * @param {string} params.stagePath - tmpfs file holding the value.
1974
+ * @param {{owner: string, level: string, token?: string}} params.target - Resolved target.
1975
+ * @param {Array<string>} params.repos - Reachable `owner/repo` slugs.
1976
+ * @returns {{written: Array<string>, failed: Array<string>}} Repositories covered and not.
1977
+ * @memberof UnderpostSecret
1978
+ */
1979
+ writeActionsSecret({ key, stagePath, target, repos }) {
1980
+ const env = Underpost.secret.ghEnvFactory(target);
1981
+ const written = [];
1982
+ const failed = [];
1983
+ const write = (scope, covered) => {
1984
+ try {
1985
+ shellExec(`bash -c 'set -o pipefail; gh secret set ${key} ${scope} < "${stagePath}"'`, {
1986
+ silent: true,
1987
+ disableLog: true,
1988
+ env,
1989
+ });
1990
+ written.push(...covered);
1991
+ logger.info(`${key} set on ${covered.join(', ')}`);
1992
+ } catch (error) {
1993
+ failed.push(...covered);
1994
+ logger.error(`${key} could not be set on ${covered.join(', ')}`, { error: error.message });
1995
+ }
1996
+ };
1997
+ const shared = target.level === 'org' ? repos.filter((slug) => slug.split('/')[0] === target.owner) : [];
1998
+ if (shared.length > 0)
1999
+ write(
2000
+ `--org "${target.owner}" --visibility selected --repos "${shared.map((slug) => slug.split('/')[1]).join(',')}"`,
2001
+ shared,
1852
2002
  );
1853
- return { token, source };
2003
+ for (const repo of repos.filter((slug) => !shared.includes(slug))) write(`--repo "${repo}"`, [repo]);
2004
+ return { written, failed };
1854
2005
  },
1855
2006
 
1856
2007
  /**
@@ -1869,7 +2020,7 @@ UNDERPOST_SOPS_ENV_EOF`,
1869
2020
  * @memberof UnderpostSecret
1870
2021
  */
1871
2022
  writeGitAuthTokenManifest(token, namespace = 'default') {
1872
- const stageDir = stageDirSync(GIT_AUTH_TOKEN_STAGE_DIR);
2023
+ const stageDir = stageDirSync(ACTIONS_SECRET_STAGE_DIR);
1873
2024
  const plaintextPath = `${stageDir}/${GIT_AUTH_TOKEN_SECRET}.yaml`;
1874
2025
  writeStageFileSync(
1875
2026
  plaintextPath,
@@ -1893,14 +2044,15 @@ UNDERPOST_SOPS_ENV_EOF`,
1893
2044
  },
1894
2045
 
1895
2046
  /**
1896
- * @method rotateGitAuthToken
1897
- * @description Replaces the `GIT_AUTH_TOKEN` Actions secret on every repository a deploy
1898
- * authenticates with, and records the new value in the encrypted store.
2047
+ * @method rotateActionsSecrets
2048
+ * @description Writes one or more GitHub Actions secrets to every repository a deploy
2049
+ * authenticates with, under a user account or an organization, and records `GIT_AUTH_TOKEN`
2050
+ * in the encrypted store when it is mirrored there.
1899
2051
  *
1900
- * GitHub is written first and the store second, because the token is only real once GitHub
1901
- * holds it: a store that leads GitHub records a credential no workflow can use, while a
2052
+ * GitHub is written first and the store second, because a credential is only real once
2053
+ * GitHub holds it: a store that leads GitHub records a value no workflow can use, while a
1902
2054
  * GitHub that leads the store converges on the next run. Every write is idempotent, so a run
1903
- * that failed part way is re-runnable with the same token.
2055
+ * that failed part way is re-runnable with the same values.
1904
2056
  *
1905
2057
  * A target that does not resolve is reported and skipped rather than aborting the rotation —
1906
2058
  * a deploy does not necessarily own every repository its naming implies, and a missing test
@@ -1908,65 +2060,111 @@ UNDERPOST_SOPS_ENV_EOF`,
1908
2060
  * fails to write is collected and raised at the end, after the repositories that did succeed
1909
2061
  * are on record.
1910
2062
  *
1911
- * The token never appears as a command argument: `gh secret set` reads it from a tmpfs file
1912
- * on stdin, so it reaches neither the process table nor the command log.
1913
- *
1914
2063
  * Usage:
1915
- * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,token=<new>,deploy-id=dd-cyberia"
2064
+ * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,value=<new>,deploy-id=dd-cyberia"
1916
2065
  * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,deploy-id=dd" --dry-run # whole fleet
1917
- * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,token=<new>,deploy-id=dd"
2066
+ * node bin secret rotate --args "secret=SOCKET_CLI_API_TOKEN|SOCKET_CLI_ORG_SLUG|NPM_TOKEN,source=host,deploy-id=dd"
2067
+ * node bin secret rotate --args "secret=NPM_TOKEN,source=host,target=org,level=org,deploy-id=template"
1918
2068
  * node bin secret rotate --args "secret=GIT_AUTH_TOKEN,store=true,apply=true"
1919
2069
  * @param {object} [options={}] - Rotation options.
1920
- * @param {string} [options.token] - Replacement token. When omitted: piped stdin, then the
1921
- * `GIT_AUTH_TOKEN` environment, then a no-echo terminal prompt.
2070
+ * @param {string} [options.keys] - Secret names, separated by `|`, `;` or whitespace.
2071
+ * @param {string} [options.value] - The value, for a single key. When omitted: piped stdin,
2072
+ * then the key's environment variable, then the host configuration, then a no-echo prompt.
2073
+ * @param {string} [options.source] - `host` reads the host configuration only.
2074
+ * @param {string} [options.env='production'] - Environment the host configuration is read for.
1922
2075
  * @param {string} [options.deployId] - Deploy id, a list separated by `|`, `;` or whitespace,
1923
2076
  * `dd` for every deploy in `engine-private/deploy/dd.routes`, or `template`.
2077
+ * @param {string} [options.target] - `user` or `org`; see {@link githubTargetFactory}.
1924
2078
  * @param {string} [options.owner] - GitHub owner for the resolved repository names.
2079
+ * @param {string} [options.level] - `repo` or `org`; see {@link githubTargetFactory}.
1925
2080
  * @param {string} [options.repos] - Extra `owner/repo` targets, separated by `|`, `;` or space.
1926
2081
  * @param {string} [options.namespace='default'] - Store namespace for the mirrored manifest.
1927
- * @param {boolean} [options.store=false] - Mirror into the encrypted store even when no
1928
- * manifest is stored yet. An existing manifest is always updated.
2082
+ * @param {boolean} [options.store=false] - Mirror `GIT_AUTH_TOKEN` into the encrypted store
2083
+ * even when no manifest is stored yet. An existing manifest is always updated.
1929
2084
  * @param {boolean} [options.apply=false] - Project the updated manifest into the cluster.
1930
2085
  * @param {boolean} [options.dryRun=false] - Report the plan without contacting GitHub,
1931
2086
  * prompting, or writing anything.
1932
- * @returns {{targets: Array<string>, rotated: Array<string>, unreachable: Array<string>,
1933
- * failed: Array<string>, manifest: string, store: boolean, tokenSource: string}} Outcome.
2087
+ * @returns {{target: object, keys: Array<string>, targets: Array<string>,
2088
+ * written: Object<string, Array<string>>, unreachable: Array<string>,
2089
+ * failed: Object<string, Array<string>>, sources: Object<string, string>, manifest: string,
2090
+ * store: boolean}} Outcome.
1934
2091
  * @memberof UnderpostSecret
1935
2092
  */
1936
- rotateGitAuthToken(options = {}) {
2093
+ rotateActionsSecrets(options = {}) {
2094
+ const keys = [
2095
+ ...new Set(
2096
+ `${options.keys ?? ''}`
2097
+ .split(/[|;\s]+/)
2098
+ .map((key) => key.trim().toUpperCase())
2099
+ .filter(Boolean),
2100
+ ),
2101
+ ];
2102
+ if (keys.length === 0) throw new Error('[secret] rotate needs at least one secret name in --args secret=');
2103
+ const invalid = keys.filter((key) => !/^(?!GITHUB_)[A-Z_][A-Z0-9_]*$/.test(key));
2104
+ if (invalid.length > 0)
2105
+ throw new Error(
2106
+ `[secret] ${invalid.join(', ')} cannot name a GitHub Actions secret: letters, digits and underscores ` +
2107
+ `only, and the GITHUB_ prefix is reserved by GitHub.`,
2108
+ );
1937
2109
  const namespace = options.namespace || 'default';
1938
- const targets = Underpost.secret.gitAuthTokenTargets(options);
2110
+ const target = Underpost.secret.githubTargetFactory(options);
2111
+ const targets = Underpost.secret.actionsSecretTargets(options);
1939
2112
  if (targets.length === 0)
1940
2113
  throw new Error(
1941
- `[secret] no repository resolved for ${GIT_AUTH_TOKEN_KEY} rotation. Name the deploy with ` +
2114
+ `[secret] no repository resolved for ${keys.join(', ')}. Name the deploy with ` +
1942
2115
  `--args deploy-id=<id>, or the repositories with --args "repos=owner/repo|owner/other".`,
1943
2116
  );
1944
- const stored = Underpost.secret.has(GIT_AUTH_TOKEN_SECRET, namespace);
1945
- const store = stored || options.store === true || `${options.store}` === 'true';
2117
+ if (keys.length > 1 && (`${options.value ?? ''}`.trim() || stdinIsRedirected()))
2118
+ throw new Error(
2119
+ `[secret] --args value= and piped stdin carry one value; with ${keys.length} keys read the ` +
2120
+ `environment or the host configuration (--args source=host).`,
2121
+ );
2122
+ const hostValues = Underpost.host.read(options.env || 'production');
2123
+ const mirrorsToken = keys.includes(GIT_AUTH_TOKEN_KEY);
2124
+ const stored = mirrorsToken && Underpost.secret.has(GIT_AUTH_TOKEN_SECRET, namespace);
2125
+ const store = mirrorsToken && (stored || options.store === true || `${options.store}` === 'true');
1946
2126
  const ghReady = Underpost.secret.hasBinary('gh');
2127
+ const ghEnv = Underpost.secret.ghEnvFactory(target);
1947
2128
  // Advisory, not a gate: `gh auth status` also exits non-zero for a logged-in account whose
1948
2129
  // token merely lacks an optional scope. Reachability of the targets is the real
1949
2130
  // precondition, so this is captured to explain a failure rather than to cause one.
1950
2131
  const ghAuth = ghReady
1951
- ? shellExec(`gh auth status 2>&1`, { silent: true, silentOnError: true, disableLog: true })
2132
+ ? shellExec(`gh auth status 2>&1`, { silent: true, silentOnError: true, disableLog: true, env: ghEnv })
1952
2133
  : null;
1953
2134
  const ghAuthenticated = ghAuth?.code === 0;
1954
2135
  const ghAuthOutput = `${ghAuth?.stdout ?? ''}`.trim() || '(no output)';
1955
2136
  // gh prefers GH_TOKEN/GITHUB_TOKEN over the account `gh auth login` stored, and this engine's
1956
2137
  // own host store exports GITHUB_TOKEN — so a stale one silently shadows a working login and
1957
- // every probe fails against a credential the operator never chose.
1958
- const shadowing = ['GH_TOKEN', 'GITHUB_TOKEN'].filter((key) => `${process.env[key] ?? ''}`.trim());
2138
+ // every probe fails against a credential the operator never chose. An explicit
2139
+ // GITHUB_SECRET_TOKEN is that choice, so nothing shadows it.
2140
+ const shadowing = target.token
2141
+ ? []
2142
+ : ['GH_TOKEN', 'GITHUB_TOKEN'].filter((key) => `${process.env[key] ?? ''}`.trim());
2143
+ const sources = Object.fromEntries(
2144
+ keys.map((key) => [key, Underpost.secret.plannedSecretSource(key, options, hostValues)]),
2145
+ );
2146
+ // Rendered as lines rather than keyed by secret name: the log redactor blanks any field
2147
+ // whose name carries "token", which would hide the very labels the operator reads.
2148
+ const lines = (byKey) => keys.map((key) => `${key} <- ${[].concat(byKey[key] ?? []).join(', ')}`);
2149
+ const where = {
2150
+ type: target.type,
2151
+ owner: target.owner,
2152
+ level: target.level,
2153
+ via: target.token ? GITHUB_TARGET_ENV.token : 'gh auth login',
2154
+ };
1959
2155
 
1960
2156
  if (options.dryRun) {
1961
2157
  // Probing is a read, so the plan reports the targets that actually exist rather than the
1962
- // ones the naming derived. Nothing is minted, prompted for, or written.
1963
- const probed = ghReady ? Underpost.secret.probeGitAuthTokenTargets(targets) : null;
1964
- logger.info(`[dry-run] ${GIT_AUTH_TOKEN_KEY} rotation plan`, {
2158
+ // ones the naming derived. Nothing is prompted for or written.
2159
+ const probed = ghReady ? Underpost.secret.probeActionsSecretTargets(targets, target) : null;
2160
+ logger.info(`[dry-run] Actions secret rotation plan`, {
2161
+ keys,
2162
+ target: where,
1965
2163
  targets,
1966
- wouldRotate: probed ? probed.reachable : '(not probed)',
2164
+ wouldWrite: probed ? probed.reachable : '(not probed)',
1967
2165
  unreachable: probed ? probed.unreachable : '(not probed)',
1968
2166
  namespace,
1969
- from: Underpost.secret.plannedTokenSource(options),
2167
+ from: lines(sources),
1970
2168
  gh: ghReady ? (ghAuthenticated ? 'authenticated' : 'not authenticated') : 'missing',
1971
2169
  manifest: store ? Underpost.secret.manifestPath(GIT_AUTH_TOKEN_SECRET, namespace) : '(store untouched)',
1972
2170
  storedManifest: stored,
@@ -1975,113 +2173,108 @@ UNDERPOST_SOPS_ENV_EOF`,
1975
2173
  else if (!ghAuthenticated)
1976
2174
  logger.warn('gh is not authenticated, so no target could be probed. Run `gh auth login`.');
1977
2175
  return {
2176
+ target,
2177
+ keys,
1978
2178
  targets,
1979
- rotated: [],
2179
+ written: {},
1980
2180
  unreachable: probed ? probed.unreachable : [],
1981
- failed: [],
2181
+ failed: {},
2182
+ sources,
1982
2183
  manifest: '',
1983
2184
  store,
1984
- tokenSource: '',
1985
2185
  };
1986
2186
  }
1987
2187
 
1988
2188
  if (!ghReady)
1989
2189
  throw new Error(
1990
2190
  `gh not found in PATH. Install the GitHub CLI (https://cli.github.com), then authenticate it with ` +
1991
- `\`gh auth login\` before rotating ${GIT_AUTH_TOKEN_KEY}.`,
2191
+ `\`gh auth login\` or set ${GITHUB_TARGET_ENV.token} before rotating ${keys.join(', ')}.`,
1992
2192
  );
1993
2193
  if (!ghAuthenticated)
1994
2194
  logger.warn(`\`gh auth status\` exited non-zero; continuing if the targets are reachable.`, {
1995
2195
  status: ghAuthOutput,
1996
2196
  });
1997
2197
 
1998
- // Probed before the token is staged: nothing can be written to an unreachable set, and
2198
+ // Probed before any value is staged: nothing can be written to an unreachable set, and
1999
2199
  // prompting for a credential that has nowhere to go wastes the operator's paste.
2000
- const probed = Underpost.secret.probeGitAuthTokenTargets(targets);
2200
+ const probed = Underpost.secret.probeActionsSecretTargets(targets, target);
2001
2201
  if (probed.reachable.length === 0)
2002
2202
  throw new Error(
2003
2203
  `None of the ${targets.length} target(s) is reachable with the current gh credential, so ` +
2004
- `${GIT_AUTH_TOKEN_KEY} was not rotated and nothing was written. Writing an Actions secret ` +
2005
- `needs the \`repo\` scope and admin on each repository.\n` +
2204
+ `${keys.join(', ')} was not rotated and nothing was written. Writing an Actions secret ` +
2205
+ `needs the \`repo\` scope and admin on each repository` +
2206
+ `${target.level === 'org' ? ', and \`admin:org\` for an organization secret' : ''}.\n` +
2006
2207
  `Targets: ${targets.join(', ')}\n` +
2007
2208
  (shadowing.length
2008
2209
  ? `${shadowing.join(' and ')} is set here, and gh uses it in preference to the account ` +
2009
2210
  `\`gh auth login\` stored. If gh calls it invalid below, run \`unset ` +
2010
- `${shadowing.join(' ')}\` and try again.\n`
2211
+ `${shadowing.join(' ')}\`, or set ${GITHUB_TARGET_ENV.token}, and try again.\n`
2011
2212
  : '') +
2012
2213
  `\`gh auth status\` reports:\n${ghAuthOutput}`,
2013
2214
  );
2014
2215
 
2015
- stageDirSync(GIT_AUTH_TOKEN_STAGE_DIR);
2016
- const stagePath = `${GIT_AUTH_TOKEN_STAGE_DIR}/${GIT_AUTH_TOKEN_KEY}`;
2017
- const rotated = [];
2216
+ stageDirSync(ACTIONS_SECRET_STAGE_DIR);
2217
+ const written = {};
2218
+ const failed = {};
2018
2219
  const unreachable = probed.unreachable;
2019
- const failed = [];
2020
2220
  let manifest = '';
2021
- let tokenSource = '';
2022
2221
  try {
2023
- const staged = Underpost.secret.stageGitAuthToken(stagePath, options);
2024
- tokenSource = staged.source;
2025
- logger.info(`Rotating ${GIT_AUTH_TOKEN_KEY}`, { targets, namespace, from: tokenSource, store });
2026
-
2027
- for (const repo of probed.reachable) {
2028
- try {
2029
- // The token arrives on stdin from the staged file, never as an argument.
2030
- shellExec(
2031
- `bash -c 'set -o pipefail; gh secret set ${GIT_AUTH_TOKEN_KEY} --repo "${repo}" < "${stagePath}"'`,
2032
- { silent: true, disableLog: true },
2033
- );
2034
- rotated.push(repo);
2035
- logger.info(`${GIT_AUTH_TOKEN_KEY} set on ${repo}`);
2036
- } catch (error) {
2037
- failed.push(repo);
2038
- logger.error(`${GIT_AUTH_TOKEN_KEY} could not be set on ${repo}`, { error: error.message });
2222
+ for (const key of keys) {
2223
+ const stagePath = `${ACTIONS_SECRET_STAGE_DIR}/${key}`;
2224
+ const staged = Underpost.secret.stageActionsSecret(stagePath, key, options, hostValues);
2225
+ sources[key] = staged.source;
2226
+ logger.info(`Rotating ${key}`, { targets: probed.reachable, target: where, from: staged.source });
2227
+ const outcome = Underpost.secret.writeActionsSecret({ key, stagePath, target, repos: probed.reachable });
2228
+ written[key] = outcome.written;
2229
+ failed[key] = outcome.failed;
2230
+
2231
+ if (key === GIT_AUTH_TOKEN_KEY && outcome.written.length > 0) {
2232
+ if (store) manifest = Underpost.secret.writeGitAuthTokenManifest(staged.value, namespace);
2233
+ else
2234
+ logger.info(
2235
+ `No ${GIT_AUTH_TOKEN_SECRET} manifest in ns/${namespace}; the store was left untouched. ` +
2236
+ `Pass --args store=true to mirror this token into it.`,
2237
+ );
2238
+ if (manifest && (options.apply === true || `${options.apply}` === 'true'))
2239
+ Underpost.secret.applyIfPresent(GIT_AUTH_TOKEN_SECRET, namespace);
2039
2240
  }
2040
2241
  }
2041
-
2042
- if (rotated.length === 0)
2043
- throw new Error(
2044
- `${GIT_AUTH_TOKEN_KEY} could not be written to any of the ${probed.reachable.length} reachable ` +
2045
- `target(s): ${probed.reachable.join(', ')}. The encrypted store was left untouched, so it still ` +
2046
- `records the credential GitHub is running on.`,
2047
- );
2048
-
2049
- if (store) manifest = Underpost.secret.writeGitAuthTokenManifest(staged.token, namespace);
2050
- else
2051
- logger.info(
2052
- `No ${GIT_AUTH_TOKEN_SECRET} manifest in ns/${namespace}; the store was left untouched. ` +
2053
- `Pass --args store=true to mirror this token into it.`,
2054
- );
2055
-
2056
- if (manifest && (options.apply === true || `${options.apply}` === 'true'))
2057
- Underpost.secret.applyIfPresent(GIT_AUTH_TOKEN_SECRET, namespace);
2058
2242
  } finally {
2059
- shellExec(`shred -u "${stagePath}" 2>/dev/null || rm -f "${stagePath}"`, {
2243
+ shellExec(`shred -u "${ACTIONS_SECRET_STAGE_DIR}"/* 2>/dev/null || rm -f "${ACTIONS_SECRET_STAGE_DIR}"/*`, {
2060
2244
  silentOnError: true,
2061
2245
  silent: true,
2062
2246
  disableLog: true,
2063
2247
  });
2064
- fs.removeSync(GIT_AUTH_TOKEN_STAGE_DIR);
2248
+ fs.removeSync(ACTIONS_SECRET_STAGE_DIR);
2065
2249
  }
2066
2250
 
2067
- const report = { targets, rotated, unreachable, failed, manifest, store, tokenSource };
2068
- if (failed.length > 0)
2251
+ const report = { target, keys, targets, written, unreachable, failed, sources, manifest, store };
2252
+ const unwritten = keys.filter((key) => written[key].length === 0);
2253
+ if (unwritten.length > 0)
2254
+ throw new Error(
2255
+ `${unwritten.join(', ')} could not be written to any of the ${probed.reachable.length} reachable ` +
2256
+ `target(s): ${probed.reachable.join(', ')}.` +
2257
+ (mirrorsToken
2258
+ ? ` The encrypted store was left untouched, so it still records the credential GitHub is running on.`
2259
+ : ''),
2260
+ );
2261
+ const split = keys.filter((key) => failed[key].length > 0);
2262
+ if (split.length > 0)
2069
2263
  throw new Error(
2070
- `${GIT_AUTH_TOKEN_KEY} was rotated on ${rotated.join(', ')} but failed on ${failed.join(', ')}. ` +
2071
- `Those repositories still hold the previous token, so the fleet is split across two credentials. ` +
2072
- `Re-run once resolved: every target is written again, and a minted token is reissued, so the ` +
2073
- `fleet converges on one value either way.`,
2264
+ split
2265
+ .map((key) => `${key} was rotated on ${written[key].join(', ')} but failed on ${failed[key].join(', ')}.`)
2266
+ .join(' ') +
2267
+ ` Those repositories still hold the previous value, so the fleet is split across two credentials. ` +
2268
+ `Re-run once resolved: every target is written again, so the fleet converges on one value either way.`,
2074
2269
  );
2075
- // Built explicitly rather than spread from `report`: the redactor blanks any field whose
2076
- // name carries "token", which would hide the source label behind [REDACTED].
2077
- logger.info(`${GIT_AUTH_TOKEN_KEY} rotation complete`, {
2270
+ logger.info(`Actions secret rotation complete`, {
2271
+ target: where,
2078
2272
  targets,
2079
- rotated,
2273
+ written: lines(written),
2080
2274
  unreachable,
2081
- failed,
2082
2275
  manifest,
2083
2276
  store,
2084
- from: tokenSource,
2277
+ from: lines(sources),
2085
2278
  });
2086
2279
  return report;
2087
2280
  },