@starci/hfs 4.12.0 → 4.13.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 (152) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +19 -7
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +31 -0
  107. package/stack/cli.d.mts +3 -1
  108. package/stack/cli.mjs +17 -11
  109. package/stack/config.d.mts +6 -0
  110. package/stack/converge.d.mts +2 -0
  111. package/stack/converge.mjs +1 -1
  112. package/stack/db-phases.d.mts +185 -0
  113. package/stack/db-phases.mjs +346 -0
  114. package/stack/deploy.d.mts +18 -4
  115. package/stack/deploy.mjs +235 -70
  116. package/stack/journal.d.mts +127 -0
  117. package/stack/journal.mjs +182 -0
  118. package/stack/manager-backup.d.mts +54 -0
  119. package/stack/manager-backup.mjs +136 -0
  120. package/stack/release-store.d.mts +44 -0
  121. package/stack/release-store.mjs +89 -6
  122. package/stack/remote.d.mts +2 -0
  123. package/stack/remote.mjs +31 -11
  124. package/stack/server-key.d.mts +2 -0
  125. package/stack/server-key.mjs +1 -1
  126. package/stack/status.d.mts +49 -0
  127. package/stack/status.mjs +114 -0
  128. package/stack/urgent-floor.d.mts +10 -61
  129. package/stack/urgent-floor.mjs +28 -123
  130. package/stack/verify.mjs +26 -0
  131. package/sync/ci-commands.mjs +10 -5
  132. package/sync/format-worker.d.mts +1 -0
  133. package/sync/format-worker.mjs +17 -0
  134. package/sync/format.d.mts +21 -3
  135. package/sync/format.mjs +99 -7
  136. package/sync/index.d.mts +3 -6
  137. package/sync/index.mjs +12 -12
  138. package/sync/stack-images.d.mts +25 -10
  139. package/sync/stack-images.mjs +215 -54
  140. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  141. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  142. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  143. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  144. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  145. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  146. package/templates/app/gitignore +1 -0
  147. package/templates/app/gitignore-lite +1 -0
  148. package/templates/app/package-scripts/db.json +1 -1
  149. package/templates/app/skeleton/.gitattributes +2 -0
  150. package/templates/app/skeleton-lite/.gitattributes +2 -0
  151. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  152. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -17,13 +17,15 @@ const CONTINUE = '\\';
17
17
  const NO_IMAGES_MESSAGE = 'no image is declared: declare environments.<env>.deploy (with images) in .starcistacks/application-stacks.yaml before running this workflow';
18
18
  const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); // reason: a YAML mapping
19
19
  const hasBuildArgs = (args) => record(args) && (Object.keys(record(args.values) ? args.values : {}).length > 0 || record(args.sealed));
20
+ const PLATFORM = /^linux\/(?:amd64|arm64)$/u;
21
+ const DEFAULT_PLATFORMS = ['linux/amd64'];
20
22
  function environmentImages(environment) {
21
23
  const images = environment?.deploy?.images;
22
24
  if (!record(images))
23
25
  return [];
24
26
  return Object.entries(images)
25
27
  .filter(([service, entry]) => NAME.test(service) && record(entry) && NAME.test(String(entry.app)))
26
- .map(([service, entry]) => ({ service, app: entry.app, buildArgs: hasBuildArgs(entry.buildArgs) }));
28
+ .map(([service, entry]) => ({ service, app: entry.app, buildArgs: hasBuildArgs(entry.buildArgs), sealed: record(entry.buildArgs) && record(entry.buildArgs.sealed) }));
27
29
  }
28
30
  /**
29
31
  * The images of a parsed declaration, [{ service, app, buildArgs }] sorted by service: the union over its environments (the first
@@ -34,7 +36,7 @@ function declaredStackImages(declaration) {
34
36
  for (const environment of Object.values(record(declaration?.environments) ? declaration.environments : {})) {
35
37
  for (const image of environmentImages(environment)) {
36
38
  const known = byService.get(image.service);
37
- byService.set(image.service, known ? { ...known, buildArgs: known.buildArgs || image.buildArgs } : image);
39
+ byService.set(image.service, known ? { ...known, buildArgs: known.buildArgs || image.buildArgs, sealed: Boolean(known.sealed || image.sealed) } : image);
38
40
  }
39
41
  }
40
42
  return [...byService.values()].sort((a, b) => byCodeUnit(a.service, b.service));
@@ -52,6 +54,16 @@ function imageEnvironments(declaration) {
52
54
  .filter(([name, environment]) => NAME.test(name) && environmentImages(environment).length > 0)
53
55
  .map(([name, environment]) => [name, environmentImages(environment).map(image => image.app)]));
54
56
  }
57
+ /** The environments that carry a deploy block, in declared order, with the auto, approval and platforms keys (defaults: manual, no reviewers, linux/amd64). */
58
+ function deployEnvironments(declaration) {
59
+ return Object.entries(record(declaration?.environments) ? declaration.environments : {})
60
+ .filter(([name, environment]) => NAME.test(name) && record(environment?.deploy))
61
+ .map(([name, environment]) => {
62
+ const deploy = environment.deploy;
63
+ const platforms = Array.isArray(deploy.platforms) ? deploy.platforms.filter((value) => typeof value === 'string' && PLATFORM.test(value)) : [];
64
+ return { name, auto: deploy.auto === true, approval: deploy.approval === 'reviewers' ? 'reviewers' : 'none', platforms: platforms.length ? platforms : DEFAULT_PLATFORMS };
65
+ });
66
+ }
55
67
  /**
56
68
  * What the managed workflows render from the declaration at `root`: { images, passEnv, imageEnvironments } (empty when the app has no
57
69
  * declaration, no deploy block or a declaration that does not parse; `starci app check` judges those). `parseYaml(text)` is injected.
@@ -71,78 +83,227 @@ export function readDeclaredStack(root, { parseYaml = bundledParseYaml } = {}) {
71
83
  }
72
84
  /** Parsed declarations share the exact image, environment and pass-env projection used by sync. */
73
85
  export function declaredStack(declaration) {
74
- return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration) };
86
+ const cache = declaration?.ci?.imageCache;
87
+ return { images: declaredStackImages(declaration), passEnv: declaredPassEnv(declaration), imageEnvironments: imageEnvironments(declaration), environments: deployEnvironments(declaration), imageCache: cache === 'registry' ? 'registry' : 'gha' };
75
88
  }
76
89
  const envName = (service) => `IMAGE_${service.toUpperCase().replaceAll('-', '_')}`;
77
90
  /** Lines placed at the end of a template line: each one starts with a newline, so nothing declared leaves nothing, not even a blank line. */
78
91
  const block = (items, indent = INDENT) => items.map(item => `\n${indent}${item}`).join('');
79
- function buildArgsStep(image) {
80
- return [
81
- `- name: public build values of ${image.service}`,
82
- ` id: args-${image.service}`,
83
- ' env:',
84
- ' SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}',
85
- ` run: npx --no-install starci app stack build-args --env "$STACK_ENVIRONMENT" --app ${image.app}`,
86
- ];
92
+ /** Lines of a block placed on a line of their own, each indented (an empty list is the empty string). */
93
+ const lines = (items, indent) => items.map(item => (item === '' ? '' : `${indent}${item}`)).join('\n');
94
+ const CHECKOUT = 'actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0';
95
+ const SETUP_NODE = 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0';
96
+ const SETUP_BUILDX = 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0';
97
+ const SETUP_QEMU = 'docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0';
98
+ const DOCKER_LOGIN = 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0';
99
+ const BUILD_PUSH = 'docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2';
100
+ const INSTALL_SOPS = 'mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0';
101
+ const TAG_MATCHES_VERSION = 'node -e "const version=require(\'./package.json\').version;if(process.env.GITHUB_REF_NAME!==\'v\'+version)process.exit(1)"';
102
+ /**
103
+ * Which of the app's images need build values, and from where. The values of the release environment (the sole image environment, or production
104
+ * when several deploy images) are the ones an image is built with; an app that environment does not deploy keeps its Dockerfile defaults.
105
+ */
106
+ function imagePlans(apps, { images, imageEnvironments: environments = {} }) {
107
+ const selected = new Set(environments[releaseEnvironment(environments)] ?? []);
108
+ return apps.map((app) => {
109
+ const own = images.filter(image => image.app === app.name);
110
+ const args = selected.has(app.name) && own.some(image => image.buildArgs);
111
+ return { ...app, deployed: own.length > 0, args, sealed: args && own.some(image => image.sealed === true), services: own.map(image => image.service) };
112
+ });
113
+ }
114
+ const matrixEntries = (plans) => plans.flatMap(plan => [`- name: ${plan.name}`, ` file: ${plan.file}`, ` deployed: ${plan.deployed}`, ` args: ${plan.args}`]);
115
+ /** The platforms every deploying environment needs, the union in a stable order (default linux/amd64: the VPS's architecture). */
116
+ function platformsOf(environments) {
117
+ const all = new Set(environments.flatMap(environment => environment.platforms));
118
+ return all.size ? [...all].sort(byCodeUnit) : DEFAULT_PLATFORMS;
119
+ }
120
+ function imageCacheLines(cache) {
121
+ if (cache === 'registry')
122
+ return [
123
+ 'cache-from: type=registry,ref=${{ steps.names.outputs.repo }}:buildcache',
124
+ "cache-to: ${{ inputs.push && format('type=registry,ref={0}:buildcache,mode=max', steps.names.outputs.repo) || '' }}",
125
+ ];
126
+ return ['cache-from: type=gha,scope=${{ matrix.name }}', 'cache-to: type=gha,scope=${{ matrix.name }},mode=max'];
87
127
  }
88
- function buildPushStep(image, dockerfile) {
89
- return [
90
- `- name: build and push ${image.service}`,
91
- ` id: build-${image.service}`,
92
- ' uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2',
128
+ /** One build job of images.yml: `image` builds with public values and no environment, `image-sealed` opens the sealed build values of the release environment. */
129
+ function imageJob(id, plans, { project, nodeMajor, environment, platforms, cache }) {
130
+ const sealed = id === 'image-sealed';
131
+ const qemu = platforms.some(platform => platform !== 'linux/amd64');
132
+ const head = [
133
+ `${id}:`,
134
+ ' timeout-minutes: 30',
135
+ ' runs-on: ubuntu-latest',
136
+ ...(sealed ? [' if: ${{ inputs.release }}', ` environment: ${environment}`] : []),
137
+ ' permissions:', ' contents: read', ' packages: write',
138
+ ' strategy:', ' fail-fast: false', ' matrix:', ' include:',
139
+ ...matrixEntries(plans).map(line => ` ${line}`),
140
+ ' steps:',
141
+ ];
142
+ const steps = [
143
+ `- uses: ${CHECKOUT}`,
144
+ '- name: release tag matches package version',
145
+ ' if: ${{ inputs.release }}',
146
+ ' shell: bash',
147
+ ' run: |',
148
+ ' [[ "$GITHUB_REF" == refs/tags/v* ]] || exit 1',
149
+ ` ${TAG_MATCHES_VERSION}`,
150
+ '- name: image repository',
151
+ ' id: names',
152
+ ' shell: bash',
153
+ ' run: |',
154
+ ' owner="${GITHUB_REPOSITORY_OWNER,,}"',
155
+ ` echo "repo=ghcr.io/\${owner}/${project}-\${{ matrix.name }}" >> "$GITHUB_OUTPUT"`,
156
+ ...(qemu ? [`- uses: ${SETUP_QEMU}`] : []),
157
+ `- uses: ${SETUP_BUILDX}`,
158
+ `- uses: ${DOCKER_LOGIN}`,
159
+ ' if: ${{ inputs.push }}',
160
+ ' with:', ' registry: ghcr.io', ' username: ${{ github.actor }}', ' password: ${{ github.token }}',
161
+ `- uses: ${SETUP_NODE}`,
162
+ ' if: ${{ matrix.args }}',
163
+ ' with:', ` node-version: ${nodeMajor}`, ' cache: npm',
164
+ '- run: npm ci --ignore-scripts',
165
+ ' if: ${{ matrix.args }}',
166
+ ...(sealed ? ['- name: install SOPS', ' if: ${{ matrix.args }}', ` uses: ${INSTALL_SOPS}`, ' with:', ' version: v3.13.2'] : []),
167
+ '- name: public build values',
168
+ ' id: args',
169
+ ' if: ${{ matrix.args }}',
170
+ ' env:',
171
+ ...(sealed ? [' SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}'] : []),
172
+ ' IMAGE_APP: ${{ matrix.name }}',
173
+ ` run: npx --no-install starci app stack build-args --env ${environment} --app "$IMAGE_APP"`,
174
+ `- uses: ${BUILD_PUSH}`,
93
175
  ' with:',
94
176
  ' context: .',
95
- ` file: ${dockerfile}`,
96
- ' push: true',
97
- ' provenance: false',
98
- ` tags: \${{ steps.names.outputs.${image.service} }}:\${{ github.sha }}`,
99
- ...(image.buildArgs ? [` build-args: \${{ steps.args-${image.service}.outputs.args }}`] : []),
100
- ` cache-from: type=gha,scope=${image.service}`,
101
- ` cache-to: type=gha,scope=${image.service},mode=max`,
177
+ ' file: ${{ matrix.file }}',
178
+ ' build-args: ${{ steps.args.outputs.args }}',
179
+ ` platforms: ${platforms.join(',')}`,
180
+ ' push: ${{ inputs.push && matrix.deployed }}',
181
+ // Immutable and per commit; the release name v<version> is added below without a rebuild, so the digest the checks proved is the one that ships.
182
+ ' tags: ${{ steps.names.outputs.repo }}:sha-${{ github.sha }}',
183
+ // The SBOM and the provenance are attached to the pushed digest (OCI attestations); a build that is not pushed is only a proof that the Dockerfile builds.
184
+ ' sbom: true',
185
+ ' provenance: mode=max',
186
+ ...imageCacheLines(cache).map(line => ` ${line}`),
187
+ '- name: tag the release',
188
+ ' if: ${{ inputs.release && inputs.push && matrix.deployed }}',
189
+ ' shell: bash',
190
+ ' env:',
191
+ ' REPO: ${{ steps.names.outputs.repo }}',
192
+ ' run: docker buildx imagetools create --tag "$REPO:$GITHUB_REF_NAME" "$REPO:sha-$GITHUB_SHA"',
102
193
  ];
194
+ return lines([...head, ...steps.map(step => ` ${step}`)], ' ');
195
+ }
196
+ /** The collect job: the digests this run pushed under sha-<sha>, as the JSON the deploy takes. The images of sealed build values exist only on a release run. */
197
+ function collectJob(plans, jobs, project) {
198
+ const waits = jobs.map(job => (job === 'image' ? "needs.image.result == 'success'" : "(needs.image-sealed.result == 'success' || needs.image-sealed.result == 'skipped')"));
199
+ const list = plans.filter(plan => plan.deployed).flatMap(plan => plan.services.map(service => `${service} ${plan.name} ${plan.sealed}`));
200
+ return lines([
201
+ 'collect:',
202
+ ` needs: [${jobs.join(', ')}]`,
203
+ ` if: \${{ !cancelled() && inputs.push && ${waits.join(' && ')} }}`,
204
+ ' timeout-minutes: 10',
205
+ ' runs-on: ubuntu-latest',
206
+ ' permissions:', ' contents: read', ' packages: read',
207
+ ' outputs:', ' refs: ${{ steps.refs.outputs.refs }}',
208
+ ' steps:',
209
+ ` - uses: ${SETUP_BUILDX}`,
210
+ ` - uses: ${DOCKER_LOGIN}`,
211
+ ' with:', ' registry: ghcr.io', ' username: ${{ github.actor }}', ' password: ${{ github.token }}',
212
+ ' - name: pushed digests',
213
+ ' id: refs',
214
+ ' shell: bash',
215
+ ' env:', ' RELEASE: ${{ inputs.release }}',
216
+ ' run: |',
217
+ ' set -euo pipefail',
218
+ ' owner="${GITHUB_REPOSITORY_OWNER,,}"',
219
+ " refs='{}'",
220
+ ' while read -r service app sealed; do',
221
+ ' if [[ "$sealed" == true && "$RELEASE" != true ]]; then continue; fi',
222
+ ` repo="ghcr.io/\${owner}/${project}-\${app}"`,
223
+ " digest=$(docker buildx imagetools inspect \"$repo:sha-$GITHUB_SHA\" --format '{{json .Manifest}}' | jq -r .digest)",
224
+ ' [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::no pushed digest for the image of ${service}"; exit 1; }',
225
+ " refs=$(jq -c --arg service \"$service\" --arg ref \"$repo@$digest\" '. + {($service): $ref}' <<<\"$refs\")",
226
+ " done <<'LIST'",
227
+ ...list.map(entry => ` ${entry}`),
228
+ ' LIST',
229
+ ' echo "refs=$refs" >> "$GITHUB_OUTPUT"',
230
+ ], ' ');
103
231
  }
104
232
  /**
105
- * The text blocks the stack-deploy.yml template places at the end of a line (each already indented for its position, every line led by
106
- * a newline; empty when there is nothing to place):
107
- * stackGuard the first step of the release job when the app declares no image (it fails with the reason)
108
- * stackNames the `names` step lines assigning each image repository (ghcr.io/<lowercase owner>/<project>-<app>)
109
- * stackImageSteps per image: the optional build-args step and the build-and-push step
110
- * stackImageEnv the env entries of the deploy step: IMAGE_<SERVICE> = repository@digest
233
+ * The text blocks of images.yml, the reusable workflow ci.yml calls. imageJobs holds the build jobs (public values; sealed values bound to the
234
+ * release environment, release runs only) and the collect job when an image is deployed; imageWorkflowOutputs the `refs` output of the call.
235
+ */
236
+ function imageWorkflowVariables(stack, project, apps, nodeMajor, environment) {
237
+ const plans = imagePlans(apps, stack);
238
+ const publicPlans = plans.filter(plan => !plan.sealed);
239
+ const sealedPlans = plans.filter(plan => plan.sealed);
240
+ const options = { project, nodeMajor, environment, platforms: platformsOf(stack.environments ?? []), cache: stack.imageCache ?? 'gha' };
241
+ const jobs = [...(publicPlans.length ? ['image'] : []), ...(sealedPlans.length ? ['image-sealed'] : [])];
242
+ const collect = plans.some(plan => plan.deployed) && jobs.length > 0;
243
+ return {
244
+ imageJobs: [
245
+ ...(publicPlans.length ? [imageJob('image', publicPlans, options)] : []),
246
+ ...(sealedPlans.length ? [imageJob('image-sealed', sealedPlans, options)] : []),
247
+ ...(collect ? [collectJob(plans, jobs, project)] : []),
248
+ ].join('\n\n'),
249
+ imageWorkflowOutputs: collect ? lines(['outputs:', ' refs:', ' description: JSON object, service -> repository@sha256:digest of the images this run pushed', ' value: ${{ jobs.collect.outputs.refs }}'], ' ') : '',
250
+ imagesCollected: collect ? 'true' : '',
251
+ };
252
+ }
253
+ /** The deploy jobs of ci.yml: one call of the reusable stack-deploy.yml per environment with deploy.auto, in declared order, each after the one before it. */
254
+ function ciDeployVariables(stack, collected) {
255
+ const auto = (stack.environments ?? []).filter(environment => environment.auto);
256
+ const jobs = auto.map((environment, index) => {
257
+ const previous = index > 0 ? `, deploy-${auto[index - 1].name}` : '';
258
+ return lines([
259
+ `deploy-${environment.name}:`,
260
+ ` needs: [plan, verdict${collected ? ', images' : ''}${previous}]`,
261
+ " if: ${{ needs.plan.outputs.release == 'true' }}",
262
+ ' permissions:', ' contents: read', ' packages: read', ' actions: read',
263
+ ' concurrency:', ` group: deploy-${environment.name}`, ' cancel-in-progress: false',
264
+ ' uses: ./.github/workflows/stack-deploy.yml',
265
+ ' with:',
266
+ ` environment: ${environment.name}`,
267
+ ' operation: deploy',
268
+ ' auto: true',
269
+ ...(collected ? [' images: ${{ needs.images.outputs.refs }}'] : []),
270
+ ' ci_run: ${{ needs.plan.outputs.ci_run }}',
271
+ ], ' ');
272
+ });
273
+ return { ciDeployJobs: jobs.length ? `\n${jobs.join('\n\n')}\n` : '' };
274
+ }
275
+ /** The release environment: the sole image environment, or production when several deploy images. */
276
+ function releaseEnvironment(environments) {
277
+ const names = Object.keys(environments);
278
+ return names.length > 1 ? 'production' : names[0] ?? 'production';
279
+ }
280
+ /**
281
+ * The text blocks the managed workflows place at the end of a line or on a line of their own (empty when there is nothing to place):
282
+ * stackGuard the first step of the deploy job when the app declares no image (it fails with the reason)
283
+ * stackImageList the `<service> <app>` lines the image step of stack-deploy.yml reads (the registry holds sha-<sha> of each)
284
+ * stackImageEnv the env entries of the deploy step: IMAGE_<SERVICE> = repository@digest, from the image step
111
285
  * stackImageArgs the `--image "<service>=$IMAGE_<SERVICE>" \` continuation lines of the deploy command
112
286
  * stackPassEnv the env entries of the deploy step for the declared host variables (vars.<NAME>)
113
- * `dockerfileOf(app)` names the Dockerfile of an app (the sync module knows the layout).
287
+ * imageJobs, imageWorkflowOutputs the build and collect jobs and the output of images.yml
288
+ * ciDeployJobs the deploy jobs of ci.yml (one per environment with deploy.auto)
289
+ * `apps` are the apps images.yml builds, each with its Dockerfile (the sync module knows the layout).
114
290
  */
115
- export function stackWorkflowVariables({ images = [], passEnv = [], imageEnvironments: environments = {} }, project, dockerfileOf) {
291
+ export function stackWorkflowVariables({ images = [], passEnv = [], imageEnvironments: environments = {}, environments: deploying = [], imageCache = 'gha' }, project, apps = [], nodeMajor = '22') {
116
292
  const guard = images.length ? '' : block([
117
293
  '- name: require a declared image',
118
294
  ' shell: bash',
119
295
  ` run: 'echo "::error::${NO_IMAGES_MESSAGE}"; exit 1'`,
120
296
  ]);
121
- const names = images.map(image => `echo "${image.service}=ghcr.io/\${owner}/${project}-${image.app}" >> "$GITHUB_OUTPUT"`);
297
+ const stack = { images, passEnv, imageEnvironments: environments, environments: deploying, imageCache };
298
+ const image = imageWorkflowVariables(stack, project, apps, nodeMajor, releaseEnvironment(environments));
122
299
  return {
123
- ...releaseImageVariables(environments),
300
+ imageEnvironment: releaseEnvironment(environments),
301
+ ...image,
302
+ ...ciDeployVariables(stack, image.imagesCollected === 'true'),
124
303
  stackGuard: guard,
125
- stackNames: block(names, ' '.repeat(10)),
126
- stackImageSteps: images.map(image => block([...(image.buildArgs ? buildArgsStep(image) : []), ...buildPushStep(image, dockerfileOf(image.app))])).join(''),
127
- stackImageEnv: block(images.map(image => `${envName(image.service)}: \${{ steps.names.outputs.${image.service} }}@\${{ steps.build-${image.service}.outputs.digest }}`), ' '.repeat(10)),
128
- stackImageArgs: block(images.map(image => `--image "${image.service}=$${envName(image.service)}" ${CONTINUE}`), ' '.repeat(12)),
304
+ stackImageList: images.map(entry => `${entry.service} ${entry.app}`).join('\n '),
305
+ stackImageEnv: block(images.map(entry => `${envName(entry.service)}: \${{ steps.images.outputs.${entry.service} }}`), ' '.repeat(10)),
306
+ stackImageArgs: block(images.map(entry => `--image "${entry.service}=$${envName(entry.service)}" ${CONTINUE}`), ' '.repeat(12)),
129
307
  stackPassEnv: block(passEnv.map(name => `${name}: \${{ vars.${name} }}`), ' '.repeat(10)),
130
308
  };
131
309
  }
132
- /** The matrix uses the deploy verb only for apps the selected environment deploys; undeployed images retain Dockerfile defaults. */
133
- function releaseImageVariables(environments) {
134
- const names = Object.keys(environments);
135
- const selectedApp = names.map(name => `(env.STACK_ENVIRONMENT == '${name}' && contains(fromJSON('${JSON.stringify(environments[name])}'), matrix.name))`).join(' || ') || 'false';
136
- const multiple = names.length > 1;
137
- const environment = multiple ? 'production' : names[0] ?? 'production';
138
- return {
139
- imageEnvironment: environment,
140
- imageJobEnvironment: names.length ? `\n environment: ${environment}` : '',
141
- imageEnvironmentGuard: multiple ? block([
142
- '- name: require a declared image environment',
143
- ` if: \${{ !contains(fromJSON('${JSON.stringify(names)}'), env.STACK_ENVIRONMENT) }}`,
144
- ' run: \'echo "::error::STACKS_DEPLOY_ENV_UNKNOWN: selected environment declares no deploy images"; exit 1\'',
145
- ]) : '',
146
- imageBuildArgsIf: `\${{ ${selectedApp} }}`,
147
- };
148
- }
@@ -1,25 +1,85 @@
1
1
  {{header}}
2
2
  name: ci
3
3
 
4
- # CI judges main, release candidates and release tags.
4
+ # CI judges pull requests, main, release candidates and release tags, and a green release tag deploys.
5
+ # pull_request the checks and the image builds of a change to main, with no secret and no environment (a Dependabot or fork run has none anyway);
6
+ # and no run of this workflow executes a pull request's code with a token that can write
7
+ # main judged only when no release tag points at the pushed commit (the release push sends main and its tag together; the tag run is the release run)
8
+ # candidate release/candidate-<version>: the full run of a release the tag run then reuses
9
+ # tag v* the release run: plan, checks and images in parallel, the verdict, then the deploy of every environment with deploy.auto
5
10
  on:
6
11
  push:
7
12
  branches: ["main", "release/candidate-*"]
8
13
  tags: ["v*"]
14
+ pull_request:
15
+ branches: ["main"]
9
16
  workflow_dispatch:
10
17
 
11
18
  permissions:
12
19
  contents: read
13
20
 
14
- # One run per ref: a release tag is never cancelled by another run.
21
+ # The committed check cache (.starcistacks/cache, contexts/project/check-cache.md): a run judges only what differs from the cache at its trusted base
22
+ # (the first parent of a push to main). A release candidate and a release tag judge in full and never read the cache.
23
+ env:
24
+ STARCI_APP_CHECK_CACHE: ${{ (startsWith(github.ref, 'refs/tags/') || startsWith(github.ref, 'refs/heads/release/candidate-')) && 'full' || '' }}
25
+
26
+ # One run per ref. A pull request run is replaced by the next push to it; every other run, a release tag above all, is never cancelled.
15
27
  concurrency:
16
28
  group: ci-${{ github.ref }}
17
- cancel-in-progress: false
29
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
18
30
 
19
- # The job shape of the StarCi CI standard (knowledge/hfs/ci-standard.yaml): fast, lint per side, the unit run in shards, one coverage merge, build,
20
- # and sonar. Timeouts and the shard count come from ci.jobs of .starcistacks/application-stacks.yaml (defaults in the standard).
31
+ # The job shape of the StarCi CI standard (knowledge/hfs/ci-standard.yaml): plan, fast, lint per side, the unit run in shards, one coverage merge, build, images,
32
+ # sonar, the verdict and the deploy jobs. Timeouts and the shard count come from ci.jobs of .starcistacks/application-stacks.yaml (defaults in the standard).
33
+ # A job the plan leaves out is never a green one: the verdict job requires every job it needs to be green or, where the plan left it out on purpose, not run.
21
34
  jobs:
35
+ plan:
36
+ timeout-minutes: 10
37
+ runs-on: ubuntu-latest
38
+ permissions:
39
+ contents: read
40
+ actions: read
41
+ outputs:
42
+ checks: ${{ steps.plan.outputs.checks }}
43
+ images: ${{ steps.plan.outputs.images }}
44
+ push: ${{ steps.plan.outputs.push }}
45
+ full: ${{ steps.plan.outputs.full }}
46
+ release: ${{ steps.plan.outputs.release }}
47
+ ci_run: ${{ steps.plan.outputs.ci_run }}
48
+ steps:
49
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
50
+ with:
51
+ fetch-depth: 0
52
+ - name: plan
53
+ id: plan
54
+ shell: bash
55
+ env:
56
+ GH_TOKEN: ${{ github.token }}
57
+ EVENT: ${{ github.event_name }}
58
+ REF_TYPE: ${{ github.ref_type }}
59
+ run: |
60
+ set -euo pipefail
61
+ checks=true images=true push=false full=false release=false ci_run="$GITHUB_RUN_ID" note="checks and image builds"
62
+ if [[ "$EVENT" == push ]]; then
63
+ full=true push=true
64
+ if [[ "$REF_TYPE" == tag ]]; then
65
+ release=true
66
+ [[ "$GITHUB_REF_NAME" == "v$(node -p "require('./package.json').version")" ]] || { echo "::error::the release tag does not match the package version"; exit 1; }
67
+ reused=$(gh api "repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?head_sha=${GITHUB_SHA}&event=push&status=success&per_page=100" --jq '[.workflow_runs[] | select(.head_branch | startswith("release/candidate-"))] | first | .id // empty')
68
+ if [[ -n "$reused" ]]; then checks=false ci_run="$reused" note="release run: the green candidate run $reused judged this commit; images are built and pushed here"; else note="release run: full checks and images"; fi
69
+ elif [[ "$GITHUB_REF" == refs/heads/release/candidate-* ]]; then
70
+ note="candidate run: full checks; images are built and pushed under sha-<sha>"
71
+ elif [[ -n "$(git tag --points-at "$GITHUB_SHA" --list 'v*')" ]]; then
72
+ checks=false images=false push=false full=false note="deferred: the release tag run of this commit judges it"
73
+ else
74
+ images=false push=false full=false note="main without a release tag: checks only"
75
+ fi
76
+ fi
77
+ { echo "checks=$checks"; echo "images=$images"; echo "push=$push"; echo "full=$full"; echo "release=$release"; echo "ci_run=$ci_run"; } >> "$GITHUB_OUTPUT"
78
+ echo "plan: $note" >> "$GITHUB_STEP_SUMMARY"
79
+
22
80
  fast:
81
+ needs: plan
82
+ if: ${{ needs.plan.outputs.checks == 'true' }}
23
83
  timeout-minutes: {{timeoutFast}}
24
84
  runs-on: ubuntu-latest
25
85
  steps:
@@ -27,13 +87,15 @@ jobs:
27
87
  - name: production dependency audit
28
88
  run: {{auditCommand}}
29
89
  - name: format
30
- run: npm run format:check
90
+ run: npx --no-install starci app check --format
31
91
  - name: typecheck
32
92
  run: npm run typecheck
33
93
  env:
34
94
  NODE_OPTIONS: {{nodeOptions}}
35
95
 
36
96
  lint:
97
+ needs: plan
98
+ if: ${{ needs.plan.outputs.checks == 'true' }}
37
99
  timeout-minutes: {{timeoutLint}}
38
100
  runs-on: ubuntu-latest
39
101
  strategy:
@@ -46,6 +108,8 @@ jobs:
46
108
  {{ciLintSteps}}
47
109
 
48
110
  test:
111
+ needs: plan
112
+ if: ${{ needs.plan.outputs.checks == 'true' }}
49
113
  timeout-minutes: {{timeoutTest}}
50
114
  runs-on: ubuntu-latest
51
115
  strategy:
@@ -69,7 +133,7 @@ jobs:
69
133
  path: {{shardCoverageReport}}
70
134
 
71
135
  coverage:
72
- needs: test
136
+ needs: [plan, test]
73
137
  timeout-minutes: {{timeoutCoverage}}
74
138
  runs-on: ubuntu-latest
75
139
  # Codecov OIDC authentication requires a job-scoped identity token.
@@ -96,6 +160,8 @@ jobs:
96
160
  {{codecovSteps}}
97
161
 
98
162
  build:
163
+ needs: plan
164
+ if: ${{ needs.plan.outputs.checks == 'true' }}
99
165
  timeout-minutes: {{timeoutBuild}}
100
166
  runs-on: ubuntu-latest
101
167
  steps:
@@ -106,3 +172,44 @@ jobs:
106
172
  {{> app/ci-workflows/build-cache-save.yml}}
107
173
 
108
174
  {{sonarJob}}
175
+ images:
176
+ needs: plan
177
+ if: ${{ needs.plan.outputs.images == 'true' }}
178
+ permissions:
179
+ contents: read
180
+ packages: write
181
+ uses: ./.github/workflows/images.yml
182
+ with:
183
+ push: ${{ needs.plan.outputs.push == 'true' }}
184
+ release: ${{ needs.plan.outputs.release == 'true' }}
185
+
186
+ verdict:
187
+ needs: [plan, fast, lint, test, coverage, build, images{{verdictSonarNeed}}]
188
+ if: ${{ always() }}
189
+ timeout-minutes: 5
190
+ runs-on: ubuntu-latest
191
+ permissions: {}
192
+ steps:
193
+ - name: verdict
194
+ shell: bash
195
+ env:
196
+ NEEDS: ${{ toJSON(needs) }}
197
+ CHECKS: ${{ needs.plan.outputs.checks }}
198
+ IMAGES: ${{ needs.plan.outputs.images }}
199
+ FULL: ${{ needs.plan.outputs.full }}
200
+ run: |
201
+ set -euo pipefail
202
+ fail=0
203
+ expect() {
204
+ got=$(jq -r --arg job "$1" '.[$job].result' <<<"$NEEDS")
205
+ case "$2" in
206
+ success) [[ "$got" == success ]] || { echo "::error::job $1 is $got, expected success"; fail=1; } ;;
207
+ idle) [[ "$got" != success && "$got" != failure && "$got" != cancelled ]] || { echo "::error::job $1 ran ($got) where the plan left it out"; fail=1; } ;;
208
+ esac
209
+ }
210
+ expect plan success
211
+ if [[ "$CHECKS" == true ]]; then want=success; else want=idle; fi
212
+ for job in fast lint test coverage build; do expect "$job" "$want"; done
213
+ if [[ "$IMAGES" == true ]]; then expect images success; else expect images idle; fi{{verdictSonarCheck}}
214
+ exit "$fail"
215
+ {{ciDeployJobs}}
@@ -1,74 +1,30 @@
1
1
  {{header}}
2
- # Builds the image of every app on its release tag, so a Dockerfile that no longer builds fails that release.
3
- # It never pushes: publishing an image is the product's decision, not this file's.
4
- # When an environment declares images, the job is bound to that protected GitHub environment and opens its sealed build
5
- # arguments with that environment's SOPS_AGE_KEY (its server identity, starci app secret server-key --env <env> --backup github --execute).
2
+ # Builds every image the app declares, once per commit, and pushes the deployed ones to GHCR. ci.yml calls it next to the checks, so the images
3
+ # are built in parallel with them and a Dockerfile that no longer builds fails the same run. It has no trigger of its own.
4
+ # tags sha-<sha> on every push; the release tag v<version> is added on a release run by retagging that digest (no rebuild), so the
5
+ # image the checks ran beside is the image that ships. No mutable environment tag is ever written or deployed.
6
+ # evidence an SBOM and provenance (mode=max) are attached to the pushed digest; a build that is not pushed only proves the Dockerfile builds.
7
+ # values build arguments are public values (the browser receives them); a secret never enters an image build.
8
+ # sealed an image whose public build values live in a sealed member is built by the job bound to the release environment, on a release
9
+ # tag run only (that environment is restricted to v* tags); every other image builds without an environment or a secret.
10
+ # The workflow is read by anyone when the repository is public: it runs on GitHub-hosted runners only, and nothing it prints names a host.
6
11
  name: images
7
12
 
8
13
  on:
9
- push:
10
- tags: ["v*"]
14
+ workflow_call:
15
+ inputs:
16
+ push:
17
+ description: Push the deployed images (a push run); a pull request or a manual run only builds them
18
+ required: true
19
+ type: boolean
20
+ release:
21
+ description: A release tag run - also tag the pushed images v<version> and build the images that open sealed build values
22
+ required: true
23
+ type: boolean
24
+ {{imageWorkflowOutputs}}
11
25
 
12
26
  permissions:
13
27
  contents: read
14
28
 
15
- # One run per ref.
16
- concurrency:
17
- group: images-${{ github.ref }}
18
- cancel-in-progress: false
19
-
20
29
  jobs:
21
- image:
22
- timeout-minutes: 30
23
- runs-on: ubuntu-latest{{imageJobEnvironment}}
24
- env:
25
- STACK_ENVIRONMENT: {{imageEnvironment}}
26
- strategy:
27
- fail-fast: false
28
- matrix:
29
- include:
30
- {{imageMatrix}}
31
- steps:{{imageEnvironmentGuard}}
32
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
33
- - name: release tag matches package version
34
- run: node -e "const version=require('./package.json').version;if(process.env.GITHUB_REF_NAME!=='v'+version)process.exit(1)"
35
- - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
36
- if: {{imageBuildArgsIf}}
37
- with:
38
- node-version: {{nodeMajor}}
39
- cache: npm
40
- - run: npm ci --ignore-scripts
41
- if: {{imageBuildArgsIf}}
42
- - name: install SOPS
43
- if: {{imageBuildArgsIf}}
44
- uses: mdgreenwald/mozilla-sops-action@bb2d96a13ef438ad122e5827a57ebd75d6ff118d # v2.1.0
45
- with:
46
- version: v3.13.2
47
- - name: public build values
48
- id: args
49
- if: {{imageBuildArgsIf}}
50
- env:
51
- SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
52
- IMAGE_APP: ${{ matrix.name }}
53
- run: npx --no-install starci app stack build-args --env "$STACK_ENVIRONMENT" --app "$IMAGE_APP"
54
- - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
55
- - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
56
- with:
57
- context: .
58
- file: ${{ matrix.file }}
59
- build-args: ${{ steps.args.outputs.args }}
60
- push: false
61
- tags: {{imageProject}}/${{ matrix.name }}:${{ github.ref_name }}
62
- # OCI keeps the SBOM and provenance attestations without publishing to a registry.
63
- outputs: type=oci,dest=${{ runner.temp }}/${{ matrix.name }}.oci.tar
64
- sbom: true
65
- provenance: mode=max
66
- cache-from: type=gha,scope=${{ matrix.name }}
67
- cache-to: type=gha,scope=${{ matrix.name }},mode=max
68
- - name: retain image with SBOM and provenance
69
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
70
- with:
71
- name: image-${{ matrix.name }}-${{ github.sha }}-${{ github.run_attempt }}
72
- path: ${{ runner.temp }}/${{ matrix.name }}.oci.tar
73
- if-no-files-found: error
74
- compression-level: 0
30
+ {{imageJobs}}