hammerkit 1.6.0-alpha.3 → 1.7.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 (256) hide show
  1. package/README.md +4 -4
  2. package/build.schema.json +49 -2
  3. package/dist/cache/backends/local-cache-backend.js +58 -1
  4. package/dist/cache/backends/registry/registry-client.js +264 -0
  5. package/dist/cache/backends/registry/registry-credentials.js +81 -0
  6. package/dist/cache/backends/registry/registry-reference.js +36 -0
  7. package/dist/cache/backends/registry-cache-backend.js +145 -0
  8. package/dist/cache/backends/s3-cache-backend.js +99 -46
  9. package/dist/cache/cache-backend-registry.js +2 -0
  10. package/dist/cache/cache-inventory.js +84 -0
  11. package/dist/cache/cache-sync.js +77 -0
  12. package/dist/cache/explain.js +214 -0
  13. package/dist/cache/last-resolved.js +48 -0
  14. package/dist/cache/read-only.js +15 -0
  15. package/dist/cache/retention.js +61 -0
  16. package/dist/cli.js +43 -0
  17. package/dist/docker/using-container.js +5 -1
  18. package/dist/executer/archive-task-entry.js +21 -0
  19. package/dist/executer/await-completed-dependencies.js +32 -14
  20. package/dist/executer/cache-metadata.js +9 -2
  21. package/dist/executer/docker-service.js +2 -1
  22. package/dist/executer/docker-task.js +2 -1
  23. package/dist/executer/dry-run.js +73 -0
  24. package/dist/executer/event-cache.js +6 -5
  25. package/dist/executer/execute-command.js +21 -3
  26. package/dist/executer/execute-work-service.js +10 -1
  27. package/dist/executer/execute-work-task.js +95 -25
  28. package/dist/executer/execute-work-tree.js +26 -1
  29. package/dist/executer/get-scheduler-execute-result.js +4 -1
  30. package/dist/executer/lazy-resolver.js +15 -3
  31. package/dist/executer/reset-work-tree.js +2 -0
  32. package/dist/executer/run-summary.js +102 -0
  33. package/dist/executer/scheduler/enqueue-next.js +15 -60
  34. package/dist/executer/scheduler/state-key.js +78 -0
  35. package/dist/kubernetes/await-running-state.js +73 -19
  36. package/dist/log.js +16 -6
  37. package/dist/logging/live-logger.js +2 -1
  38. package/dist/optimizer/calculate-checksum.js +8 -2
  39. package/dist/optimizer/get-work-cache-stats.js +66 -16
  40. package/dist/optimizer/work-task-cache-description.js +34 -5
  41. package/dist/planner/project-root.js +26 -0
  42. package/dist/planner/utils/append-work-service.js +2 -1
  43. package/dist/planner/utils/append-work-task.js +7 -1
  44. package/dist/planner/utils/parse-work-source.js +35 -30
  45. package/dist/planner/utils/portable-path.js +30 -0
  46. package/dist/planner/work-cache-id.js +2 -2
  47. package/dist/planner/work-graph-serializer.js +103 -0
  48. package/dist/planner/work-instance-id.js +15 -0
  49. package/dist/planner/work-runtime-docker.js +53 -15
  50. package/dist/planner/work-runtime-kubernetes.js +35 -3
  51. package/dist/planner/work-runtime-local.js +9 -3
  52. package/dist/planner/work-service-id.js +16 -4
  53. package/dist/program.js +235 -1
  54. package/dist/schema/build-file-base-task-schema.js +4 -0
  55. package/dist/schema/cache-schema.js +21 -2
  56. package/dist/schema/duration-schema.js +18 -0
  57. package/dist/schema/reference-parser.js +3 -0
  58. package/dist/schema/size-schema.js +18 -0
  59. package/dist/schema/work-scope-parser.js +15 -2
  60. package/dist/utils/units.js +87 -0
  61. package/package.json +15 -4
  62. package/.agents/FEEDBACK.md +0 -74
  63. package/.agents/skills/before-commit/SKILL.md +0 -101
  64. package/.agents/skills/do-the-work/SKILL.md +0 -64
  65. package/.agents/skills/fix-ci/SKILL.md +0 -98
  66. package/.agents/skills/speckit-git-commit/SKILL.md +0 -53
  67. package/.agents/skills/speckit-git-feature/SKILL.md +0 -72
  68. package/.agents/skills/speckit-git-initialize/SKILL.md +0 -54
  69. package/.agents/skills/speckit-git-remote/SKILL.md +0 -50
  70. package/.agents/skills/speckit-git-validate/SKILL.md +0 -54
  71. package/.devcontainer/Dockerfile +0 -5
  72. package/.devcontainer/devcontainer.json +0 -40
  73. package/.gitbook.yaml +0 -5
  74. package/.specify/extensions/.registry +0 -37
  75. package/.specify/extensions/git/README.md +0 -100
  76. package/.specify/extensions/git/commands/speckit.git.commit.md +0 -48
  77. package/.specify/extensions/git/commands/speckit.git.feature.md +0 -67
  78. package/.specify/extensions/git/commands/speckit.git.initialize.md +0 -49
  79. package/.specify/extensions/git/commands/speckit.git.remote.md +0 -45
  80. package/.specify/extensions/git/commands/speckit.git.validate.md +0 -49
  81. package/.specify/extensions/git/config-template.yml +0 -62
  82. package/.specify/extensions/git/extension.yml +0 -140
  83. package/.specify/extensions/git/git-config.yml +0 -62
  84. package/.specify/extensions.yml +0 -149
  85. package/.specify/feature.json +0 -3
  86. package/.specify/init-options.json +0 -10
  87. package/.specify/integration.json +0 -15
  88. package/.specify/integrations/claude.manifest.json +0 -16
  89. package/.specify/integrations/speckit.manifest.json +0 -17
  90. package/.specify/memory/constitution.md +0 -162
  91. package/.specify/templates/checklist-template.md +0 -40
  92. package/.specify/templates/constitution-template.md +0 -50
  93. package/.specify/templates/plan-template.md +0 -113
  94. package/.specify/templates/spec-template.md +0 -131
  95. package/.specify/templates/tasks-template.md +0 -252
  96. package/.specify/workflows/speckit/workflow.yml +0 -77
  97. package/.specify/workflows/workflow-registry.json +0 -13
  98. package/AGENTS.md +0 -94
  99. package/Dockerfile +0 -25
  100. package/FOLLOWUPS.md +0 -34
  101. package/SUMMARY.md +0 -68
  102. package/VALUES.md +0 -53
  103. package/dist/cache/auto-pull.spec.js +0 -123
  104. package/dist/cache/backends/local-cache-backend.spec.js +0 -72
  105. package/dist/cache/backends/s3-cache-backend.spec.js +0 -118
  106. package/dist/cache/cache-backend-registry.spec.js +0 -22
  107. package/dist/cache/resolve-cache.spec.js +0 -52
  108. package/dist/cli.spec.js +0 -23
  109. package/dist/console/get-logger.spec.js +0 -23
  110. package/dist/docker/get-default-platform.spec.js +0 -29
  111. package/dist/docker/longest-common-prefix.spec.js +0 -26
  112. package/dist/docker/package.spec.js +0 -145
  113. package/dist/docker/remove-container.spec.js +0 -32
  114. package/dist/docker/using-container.spec.js +0 -90
  115. package/dist/environment/merge-environment-variables.spec.js +0 -24
  116. package/dist/environment/replace-env-variables.spec.js +0 -47
  117. package/dist/executer/abort.spec.js +0 -18
  118. package/dist/executer/environment-mock.spec.js +0 -22
  119. package/dist/executer/execute.spec.js +0 -51
  120. package/dist/executer/get-service-containers.spec.js +0 -59
  121. package/dist/executer/get-service-env-hints.spec.js +0 -65
  122. package/dist/executer/label-values.spec.js +0 -61
  123. package/dist/executer/lazy-resolver.spec.js +0 -20
  124. package/dist/executer/process-manager.spec.js +0 -54
  125. package/dist/executer/scheduler/enqueue-next.spec.js +0 -136
  126. package/dist/executer/state-resolver.spec.js +0 -37
  127. package/dist/executer/state.spec.js +0 -66
  128. package/dist/executer/states.spec.js +0 -14
  129. package/dist/file/get-file-context.spec.js +0 -178
  130. package/dist/file/move-files.spec.js +0 -48
  131. package/dist/kubernetes/ensure-http-route.spec.js +0 -70
  132. package/dist/kubernetes/ensure-namespace.spec.js +0 -39
  133. package/dist/kubernetes/resolve-pod-name.spec.js +0 -81
  134. package/dist/log.spec.js +0 -131
  135. package/dist/logging/grouped-logger.spec.js +0 -84
  136. package/dist/logging/interactive-logger.spec.js +0 -57
  137. package/dist/logging/live-logger.spec.js +0 -37
  138. package/dist/logging/loggers.spec.js +0 -154
  139. package/dist/optimizer/calculate-checksum.spec.js +0 -34
  140. package/dist/optimizer/get-cache-directory.spec.js +0 -59
  141. package/dist/optimizer/get-work-cache-stats.spec.js +0 -79
  142. package/dist/optimizer/work-task-cache-description.spec.js +0 -45
  143. package/dist/parser/default-build-file.spec.js +0 -48
  144. package/dist/parser/parse-label-arguments.spec.js +0 -27
  145. package/dist/parser/read-build-file.spec.js +0 -89
  146. package/dist/parser/read-env-file.spec.js +0 -42
  147. package/dist/planner/utils/append-work-service.spec.js +0 -71
  148. package/dist/planner/utils/get-container-user.spec.js +0 -30
  149. package/dist/planner/utils/get-default-kube-config.spec.js +0 -11
  150. package/dist/planner/utils/normalize-path.spec.js +0 -24
  151. package/dist/planner/utils/parse-work-command.spec.js +0 -21
  152. package/dist/planner/utils/parse-work-generate.spec.js +0 -45
  153. package/dist/planner/utils/parse-work-mount.spec.js +0 -62
  154. package/dist/planner/utils/parse-work-mounts.spec.js +0 -19
  155. package/dist/planner/utils/parse-work-port.spec.js +0 -24
  156. package/dist/planner/utils/parse-work-ports.spec.js +0 -24
  157. package/dist/planner/utils/parse-work-source.spec.js +0 -58
  158. package/dist/planner/utils/parse-work-volume.spec.js +0 -18
  159. package/dist/planner/utils/plan-work-tasks.spec.js +0 -30
  160. package/dist/planner/utils/plan-work-volume.spec.js +0 -33
  161. package/dist/planner/utils/split-name.spec.js +0 -16
  162. package/dist/planner/utils/template-value.spec.js +0 -14
  163. package/dist/planner/validate.spec.js +0 -251
  164. package/dist/planner/work-cache-id.spec.js +0 -173
  165. package/dist/planner/work-runtime-local.spec.js +0 -193
  166. package/dist/program-commands.spec.js +0 -84
  167. package/dist/program.spec.js +0 -43
  168. package/dist/schema/build-file-schema.spec.js +0 -61
  169. package/dist/schema/doc-build-files.spec.js +0 -223
  170. package/dist/schema/parse-error.spec.js +0 -18
  171. package/dist/schema/reference-parser.spec.js +0 -83
  172. package/dist/schema/schema-parser.spec.js +0 -80
  173. package/dist/schema/type-guards.spec.js +0 -23
  174. package/dist/schema/work-scope-parser.spec.js +0 -66
  175. package/dist/service/update-service-status.spec.js +0 -27
  176. package/dist/testing/ensure-local-registry.js +0 -134
  177. package/dist/testing/example-test-suite.js +0 -63
  178. package/dist/testing/expect.js +0 -82
  179. package/dist/testing/get-test-suite.js +0 -9
  180. package/dist/testing/http-get.js +0 -21
  181. package/dist/testing/integration/cache.spec.js +0 -49
  182. package/dist/testing/integration/cancellation.spec.js +0 -45
  183. package/dist/testing/integration/clean.spec.js +0 -43
  184. package/dist/testing/integration/cmd.spec.js +0 -15
  185. package/dist/testing/integration/concurrency.spec.js +0 -19
  186. package/dist/testing/integration/docker-package-registry.spec.js +0 -44
  187. package/dist/testing/integration/docker-service-crash.spec.js +0 -36
  188. package/dist/testing/integration/docker.spec.js +0 -17
  189. package/dist/testing/integration/env.spec.js +0 -48
  190. package/dist/testing/integration/environment.spec.js +0 -97
  191. package/dist/testing/integration/error.spec.js +0 -19
  192. package/dist/testing/integration/export.spec.js +0 -22
  193. package/dist/testing/integration/extend.spec.js +0 -29
  194. package/dist/testing/integration/glob.spec.js +0 -40
  195. package/dist/testing/integration/include.spec.js +0 -31
  196. package/dist/testing/integration/invalid.spec.js +0 -17
  197. package/dist/testing/integration/invalid_loop.spec.js +0 -56
  198. package/dist/testing/integration/k8s-state-cache.spec.js +0 -41
  199. package/dist/testing/integration/kubernetes-service.spec.js +0 -148
  200. package/dist/testing/integration/kubernetes.spec.js +0 -99
  201. package/dist/testing/integration/labels.spec.js +0 -42
  202. package/dist/testing/integration/local.spec.js +0 -14
  203. package/dist/testing/integration/monorepo.spec.js +0 -15
  204. package/dist/testing/integration/multi-line.spec.js +0 -17
  205. package/dist/testing/integration/reference.spec.js +0 -21
  206. package/dist/testing/integration/services.spec.js +0 -38
  207. package/dist/testing/integration/store.spec.js +0 -52
  208. package/dist/testing/integration/unknown.spec.js +0 -28
  209. package/dist/testing/integration/validate.spec.js +0 -160
  210. package/dist/testing/integration/watch-dependency.spec.js +0 -33
  211. package/dist/testing/integration/watch.spec.js +0 -45
  212. package/dist/testing/requires-kubernetes.js +0 -18
  213. package/dist/testing/requires-linux-containers.js +0 -18
  214. package/dist/testing/requires-windows-containers.js +0 -18
  215. package/dist/testing/test-case.js +0 -75
  216. package/dist/testing/test-streams.js +0 -28
  217. package/dist/testing/test-suite-setup.js +0 -3
  218. package/dist/testing/test-suite.js +0 -3
  219. package/dist/testing/testing-timeout.js +0 -32
  220. package/dist/utils/abort-event.spec.js +0 -46
  221. package/dist/utils/buffer-context.spec.js +0 -38
  222. package/dist/utils/ci.spec.js +0 -53
  223. package/dist/utils/debouncer.spec.js +0 -58
  224. package/dist/utils/emitter.spec.js +0 -34
  225. package/dist/utils/empty-writable.spec.js +0 -18
  226. package/dist/utils/environment-config.spec.js +0 -26
  227. package/dist/utils/fail-never.spec.js +0 -9
  228. package/dist/utils/sleep.spec.js +0 -22
  229. package/dist/version.spec.js +0 -12
  230. package/specs/build-file/caches/spec.md +0 -114
  231. package/specs/build-file/environment-variables/spec.md +0 -82
  232. package/specs/build-file/includes/spec.md +0 -60
  233. package/specs/build-file/references/spec.md +0 -56
  234. package/specs/build-file/spec.md +0 -62
  235. package/specs/cli/clean/spec.md +0 -85
  236. package/specs/cli/down/spec.md +0 -85
  237. package/specs/cli/execute/spec.md +0 -101
  238. package/specs/cli/init/spec.md +0 -64
  239. package/specs/cli/ls/spec.md +0 -66
  240. package/specs/cli/package/spec.md +0 -110
  241. package/specs/cli/spec.md +0 -73
  242. package/specs/cli/store-restore/spec.md +0 -74
  243. package/specs/cli/up/spec.md +0 -80
  244. package/specs/cli/validate/spec.md +0 -70
  245. package/specs/labels/spec.md +0 -91
  246. package/specs/service/container/spec.md +0 -120
  247. package/specs/service/kubernetes/spec.md +0 -97
  248. package/specs/service/spec.md +0 -105
  249. package/specs/task/caching/spec.md +0 -107
  250. package/specs/task/container/spec.md +0 -118
  251. package/specs/task/dependencies/spec.md +0 -70
  252. package/specs/task/extending/spec.md +0 -74
  253. package/specs/task/kubernetes/spec.md +0 -125
  254. package/specs/task/needs/spec.md +0 -59
  255. package/specs/task/spec.md +0 -87
  256. package/specs/task/watching/spec.md +0 -69
package/README.md CHANGED
@@ -4,22 +4,22 @@
4
4
 
5
5
  ## What is hammerkit?
6
6
 
7
- Hammerkit is a build tool intended to build software projects in **containers** locally and in a CI. The advantages of containerized builds are:
7
+ Hammerkit is a build tool intended to build software projects in **containers** locally and in CI. The advantages of containerized builds are:
8
8
 
9
9
  * the build tools come from the container image, so they are the same across all machines.
10
10
  * the build process is isolated since every file in the container has to be declared as a source or an output. Therefore, side effects can be reduced.
11
11
 
12
12
  For a fuller pitch — including how it compares to Make, npm scripts, Taskfile and Earthly — see [Why hammerkit](docs/why-hammerkit.md).
13
13
 
14
- There are a lot of containerized CI systems that allow writing containerized builds today, but they lack some features hammerkit tries to solve:
14
+ There are a lot of containerized CI systems that allow writing containerized builds today, but they have shortcomings that hammerkit tries to address:
15
15
 
16
16
  * they are often not **usable during development** on your local machine, so you either have to maintain two build scripts or wrap one of them in the other.
17
17
  * they mount the entire repository into the container, so uncontrolled **side effects** can reduce reliability.
18
18
  * some of them do not allow **switching images** between build steps, requiring you to maintain one large build image that contains every tool needed to build the software.
19
19
 
20
- Additionally, hammerkit tries to reduce complexity of build caching in CI systems. Usually you define a directory that is being cached and restored before and after the CI build. That's totally fine for smaller project where there are not that many directories to cache. The bigger the project gets, the more effort it is, to keep all directories up to date.
20
+ Additionally, hammerkit tries to reduce the complexity of build caching in CI systems. Usually you define a directory that is restored before and cached after the CI build. That's fine for smaller projects with only a few directories to cache. The bigger the project gets, the more effort it takes to keep all of those directories up to date.
21
21
 
22
- Hammerkit on the other hand knows the source and output files of each build step and can therefore compact the build results into a single directory. Making it easy to being cached. Once restored, hammerkit can detect what's changed since last time and only build the changes.
22
+ Hammerkit, on the other hand, knows the source and output files of each build step and can therefore compact the build results into a single directory, which is easy to cache — or share them through a remote cache. Once restored, hammerkit detects what has changed since the last run and only builds the changes.
23
23
 
24
24
  ## Where to go next?
25
25
 
package/build.schema.json CHANGED
@@ -164,6 +164,12 @@
164
164
  "shell": {
165
165
  "type": "string"
166
166
  },
167
+ "continuous": {
168
+ "type": "boolean"
169
+ },
170
+ "timeout": {
171
+ "type": "string"
172
+ },
167
173
  "image": {
168
174
  "type": "string"
169
175
  },
@@ -216,6 +222,12 @@
216
222
  "shell": {
217
223
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/shell"
218
224
  },
225
+ "continuous": {
226
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/continuous"
227
+ },
228
+ "timeout": {
229
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout"
230
+ },
219
231
  "platform": {
220
232
  "type": "object",
221
233
  "properties": {
@@ -323,8 +335,7 @@
323
335
  }
324
336
  },
325
337
  "required": [
326
- "image",
327
- "ports"
338
+ "image"
328
339
  ],
329
340
  "additionalProperties": false,
330
341
  "description": "container service"
@@ -545,8 +556,44 @@
545
556
  "bucket"
546
557
  ],
547
558
  "additionalProperties": false
559
+ },
560
+ {
561
+ "type": "object",
562
+ "properties": {
563
+ "type": {
564
+ "type": "string",
565
+ "const": "registry"
566
+ },
567
+ "repository": {
568
+ "type": "string"
569
+ },
570
+ "insecure": {
571
+ "type": "boolean"
572
+ }
573
+ },
574
+ "required": [
575
+ "type",
576
+ "repository"
577
+ ],
578
+ "additionalProperties": false
548
579
  }
549
580
  ]
581
+ },
582
+ "retention": {
583
+ "type": "object",
584
+ "properties": {
585
+ "maxAge": {
586
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout"
587
+ },
588
+ "maxSize": {
589
+ "type": "string"
590
+ },
591
+ "keepPerTask": {
592
+ "type": "integer",
593
+ "exclusiveMinimum": 0
594
+ }
595
+ },
596
+ "additionalProperties": false
550
597
  }
551
598
  },
552
599
  "required": [
@@ -3,8 +3,43 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createLocalCacheBackend = void 0;
4
4
  const path_1 = require("path");
5
5
  const os_1 = require("os");
6
+ const promises_1 = require("fs/promises");
7
+ // Hammerkit-owned last-use marker inside each entry directory, updated on every
8
+ // pull. Filesystem atime is unreliable (relatime/noatime), so retention reads
9
+ // this instead. Never copied out of the cache.
10
+ const LAST_ACCESS_FILE = '.last-access';
11
+ async function directories(path) {
12
+ try {
13
+ return (await (0, promises_1.readdir)(path, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
14
+ }
15
+ catch {
16
+ return [];
17
+ }
18
+ }
6
19
  function createLocalCacheBackend(spec) {
7
20
  const root = spec.path ?? (0, path_1.join)((0, os_1.homedir)(), '.hammerkit', 'remote-cache');
21
+ async function readEntry(taskId, stateKey) {
22
+ const dir = (0, path_1.join)(root, taskId, stateKey);
23
+ let created;
24
+ try {
25
+ // stats.json is written last, so its presence marks a complete entry
26
+ created = (await (0, promises_1.stat)((0, path_1.join)(dir, 'stats.json'))).mtimeMs;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ let size = 0;
32
+ let lastAccessedAt = null;
33
+ for (const file of await (0, promises_1.readdir)(dir)) {
34
+ if (file === LAST_ACCESS_FILE) {
35
+ const value = parseInt(await (0, promises_1.readFile)((0, path_1.join)(dir, file), 'utf8'), 10);
36
+ lastAccessedAt = Number.isNaN(value) ? null : value;
37
+ continue;
38
+ }
39
+ size += (await (0, promises_1.stat)((0, path_1.join)(dir, file))).size;
40
+ }
41
+ return { taskId, stateKey, size, createdAt: Math.round(created), lastAccessedAt };
42
+ }
8
43
  return {
9
44
  type: 'local',
10
45
  async has(taskId, stateKey, environment) {
@@ -25,8 +60,12 @@ function createLocalCacheBackend(spec) {
25
60
  await environment.file.createDirectory(into);
26
61
  try {
27
62
  for (const file of await environment.file.listFiles(remoteDir)) {
63
+ if (file === LAST_ACCESS_FILE) {
64
+ continue;
65
+ }
28
66
  await environment.file.copy((0, path_1.join)(remoteDir, file), (0, path_1.join)(into, file));
29
67
  }
68
+ await (0, promises_1.writeFile)((0, path_1.join)(remoteDir, LAST_ACCESS_FILE), `${Date.now()}`);
30
69
  return true;
31
70
  }
32
71
  catch (e) {
@@ -37,7 +76,7 @@ function createLocalCacheBackend(spec) {
37
76
  const remoteDir = (0, path_1.join)(root, taskId, stateKey);
38
77
  await environment.file.createDirectory(remoteDir);
39
78
  // Write data files first; stats.json last so a partial push is invisible to has()/pull().
40
- const files = await environment.file.listFiles(from);
79
+ const files = (await environment.file.listFiles(from)).filter((f) => f !== LAST_ACCESS_FILE);
41
80
  const ordered = [
42
81
  ...files.filter((f) => f !== 'stats.json' && f !== 'description.json'),
43
82
  ...files.filter((f) => f === 'description.json'),
@@ -50,6 +89,24 @@ function createLocalCacheBackend(spec) {
50
89
  async clear(taskId, environment) {
51
90
  await environment.file.remove((0, path_1.join)(root, taskId));
52
91
  },
92
+ async list() {
93
+ const entries = [];
94
+ for (const taskId of await directories(root)) {
95
+ for (const stateKey of await directories((0, path_1.join)(root, taskId))) {
96
+ const entry = await readEntry(taskId, stateKey);
97
+ if (entry) {
98
+ entries.push(entry);
99
+ }
100
+ }
101
+ }
102
+ return entries;
103
+ },
104
+ async remove(taskId, stateKey, environment) {
105
+ await environment.file.remove((0, path_1.join)(root, taskId, stateKey));
106
+ if ((await directories((0, path_1.join)(root, taskId))).length === 0) {
107
+ await environment.file.remove((0, path_1.join)(root, taskId));
108
+ }
109
+ },
53
110
  };
54
111
  }
55
112
  exports.createLocalCacheBackend = createLocalCacheBackend;
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.digestBuffer = exports.digestFile = exports.RegistryClient = exports.parseChallenge = exports.RegistryError = exports.OCI_LAYER_TAR = exports.OCI_CONFIG = exports.OCI_MANIFEST = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const fs_1 = require("fs");
6
+ const promises_1 = require("fs/promises");
7
+ const stream_1 = require("stream");
8
+ const promises_2 = require("stream/promises");
9
+ const registry_credentials_1 = require("./registry-credentials");
10
+ exports.OCI_MANIFEST = 'application/vnd.oci.image.manifest.v1+json';
11
+ exports.OCI_CONFIG = 'application/vnd.oci.image.config.v1+json';
12
+ exports.OCI_LAYER_TAR = 'application/vnd.oci.image.layer.v1.tar';
13
+ const MANIFEST_ACCEPT = [exports.OCI_MANIFEST, 'application/vnd.docker.distribution.manifest.v2+json'].join(', ');
14
+ class RegistryError extends Error {
15
+ constructor(message, status) {
16
+ super(message);
17
+ this.status = status;
18
+ }
19
+ }
20
+ exports.RegistryError = RegistryError;
21
+ // Parse a `WWW-Authenticate` header, e.g.
22
+ // `Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:o/r:pull"`
23
+ function parseChallenge(header) {
24
+ const space = header.indexOf(' ');
25
+ const scheme = (space < 0 ? header : header.substring(0, space)).toLowerCase();
26
+ const params = {};
27
+ const regex = /([a-zA-Z_]+)="([^"]*)"/g;
28
+ let match;
29
+ while ((match = regex.exec(header)) !== null) {
30
+ params[match[1]] = match[2];
31
+ }
32
+ return { scheme, params };
33
+ }
34
+ exports.parseChallenge = parseChallenge;
35
+ function basicHeader(credentials) {
36
+ return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
37
+ }
38
+ // A minimal OCI distribution-spec client: just the manifest/blob operations the
39
+ // cache backend needs, with the Basic and Bearer-token auth handshakes every
40
+ // mainstream registry (Docker Hub, GHCR, ECR, GAR, Artifactory, registry:2)
41
+ // uses. Talks HTTP directly so no docker daemon or extra binary is involved.
42
+ class RegistryClient {
43
+ constructor(reference, environment) {
44
+ this.reference = reference;
45
+ this.environment = environment;
46
+ this.authorization = null;
47
+ this.credentials = undefined;
48
+ }
49
+ url(path) {
50
+ const scheme = this.reference.insecure ? 'http' : 'https';
51
+ return `${scheme}://${this.reference.host}/v2/${this.reference.repository}/${path}`;
52
+ }
53
+ async getCredentials() {
54
+ if (this.credentials === undefined) {
55
+ this.credentials = await (0, registry_credentials_1.resolveRegistryCredentials)(this.reference.credentialHost, this.environment);
56
+ }
57
+ return this.credentials;
58
+ }
59
+ async authenticate(challengeHeader) {
60
+ if (!challengeHeader) {
61
+ return false;
62
+ }
63
+ const challenge = parseChallenge(challengeHeader);
64
+ const credentials = await this.getCredentials();
65
+ if (challenge.scheme === 'basic') {
66
+ if (!credentials) {
67
+ return false;
68
+ }
69
+ this.authorization = basicHeader(credentials);
70
+ return true;
71
+ }
72
+ if (challenge.scheme !== 'bearer' || !challenge.params.realm) {
73
+ return false;
74
+ }
75
+ const tokenUrl = new URL(challenge.params.realm);
76
+ if (challenge.params.service) {
77
+ tokenUrl.searchParams.set('service', challenge.params.service);
78
+ }
79
+ // always ask for push too: one token then serves the whole backend; a
80
+ // registry grants the subset the credentials allow
81
+ tokenUrl.searchParams.set('scope', `repository:${this.reference.repository}:pull,push`);
82
+ const response = await fetch(tokenUrl, {
83
+ headers: credentials ? { authorization: basicHeader(credentials) } : {},
84
+ });
85
+ if (!response.ok) {
86
+ throw new RegistryError(`registry token request failed with ${response.status}`, response.status);
87
+ }
88
+ const body = (await response.json());
89
+ const token = body.token ?? body.access_token;
90
+ if (!token) {
91
+ throw new RegistryError('registry token response contained no token');
92
+ }
93
+ this.authorization = `Bearer ${token}`;
94
+ return true;
95
+ }
96
+ // Send a request, performing the auth handshake once on a 401. `body` is a
97
+ // factory because a streamed body cannot be replayed after the challenge.
98
+ async request(method, urlOrPath, init = {}) {
99
+ const url = urlOrPath.startsWith('http') ? urlOrPath : this.url(urlOrPath);
100
+ const send = () => {
101
+ const body = init.body?.();
102
+ return fetch(url, {
103
+ method,
104
+ headers: { ...(init.headers ?? {}), ...(this.authorization ? { authorization: this.authorization } : {}) },
105
+ body: body instanceof stream_1.Readable ? stream_1.Readable.toWeb(body) : body,
106
+ redirect: init.redirect ?? 'follow',
107
+ ...(body instanceof stream_1.Readable ? { duplex: 'half' } : {}),
108
+ });
109
+ };
110
+ let response = await send();
111
+ if (response.status === 401 && (await this.authenticate(response.headers.get('www-authenticate')))) {
112
+ await response.body?.cancel();
113
+ response = await send();
114
+ }
115
+ return response;
116
+ }
117
+ async fail(response, what) {
118
+ const text = await response.text().catch(() => '');
119
+ const hint = response.status === 401 || response.status === 403 ? ' (check `docker login` for this registry)' : '';
120
+ throw new RegistryError(`${what} failed with ${response.status} ${text.substring(0, 200)}${hint}`.trim(), response.status);
121
+ }
122
+ async manifestExists(tag) {
123
+ const response = await this.request('HEAD', `manifests/${tag}`, { headers: { accept: MANIFEST_ACCEPT } });
124
+ if (response.status === 404) {
125
+ return false;
126
+ }
127
+ if (!response.ok) {
128
+ return this.fail(response, `checking manifest ${tag}`);
129
+ }
130
+ return true;
131
+ }
132
+ async getManifest(tag) {
133
+ const response = await this.request('GET', `manifests/${tag}`, { headers: { accept: MANIFEST_ACCEPT } });
134
+ if (response.status === 404) {
135
+ return null;
136
+ }
137
+ if (!response.ok) {
138
+ return this.fail(response, `reading manifest ${tag}`);
139
+ }
140
+ return (await response.json());
141
+ }
142
+ async getBlobJson(digest) {
143
+ const response = await this.request('GET', `blobs/${digest}`);
144
+ if (!response.ok) {
145
+ return this.fail(response, `reading blob ${digest}`);
146
+ }
147
+ return (await response.json());
148
+ }
149
+ async manifestDigest(tag) {
150
+ const response = await this.request('HEAD', `manifests/${tag}`, { headers: { accept: MANIFEST_ACCEPT } });
151
+ if (response.status === 404) {
152
+ return null;
153
+ }
154
+ if (!response.ok) {
155
+ return this.fail(response, `checking manifest ${tag}`);
156
+ }
157
+ return response.headers.get('docker-content-digest');
158
+ }
159
+ async putManifest(tag, manifest) {
160
+ const body = JSON.stringify(manifest);
161
+ const response = await this.request('PUT', `manifests/${tag}`, {
162
+ headers: { 'content-type': exports.OCI_MANIFEST },
163
+ body: () => body,
164
+ });
165
+ if (!response.ok) {
166
+ return this.fail(response, `writing manifest ${tag}`);
167
+ }
168
+ }
169
+ async deleteManifest(digest) {
170
+ const response = await this.request('DELETE', `manifests/${digest}`);
171
+ if (response.status === 404) {
172
+ return;
173
+ }
174
+ if (response.status === 405 || response.status === 403 || response.status === 400) {
175
+ throw new RegistryError(`the registry does not allow deleting cache entries (${response.status}); use its own retention policy instead`, response.status);
176
+ }
177
+ if (!response.ok) {
178
+ return this.fail(response, `deleting manifest ${digest}`);
179
+ }
180
+ }
181
+ async listTags() {
182
+ const tags = [];
183
+ let next = 'tags/list?n=1000';
184
+ while (next) {
185
+ const response = await this.request('GET', next);
186
+ if (response.status === 404) {
187
+ return tags;
188
+ }
189
+ if (!response.ok) {
190
+ return this.fail(response, 'listing tags');
191
+ }
192
+ const body = (await response.json());
193
+ tags.push(...(body.tags ?? []));
194
+ const link = response.headers.get('link');
195
+ const match = link ? /<([^>]+)>;\s*rel="next"/.exec(link) : null;
196
+ next = match ? new URL(match[1], this.url('')).toString() : null;
197
+ }
198
+ return tags;
199
+ }
200
+ async blobExists(digest) {
201
+ const response = await this.request('HEAD', `blobs/${digest}`);
202
+ if (response.status === 404) {
203
+ return false;
204
+ }
205
+ if (!response.ok) {
206
+ return this.fail(response, `checking blob ${digest}`);
207
+ }
208
+ return true;
209
+ }
210
+ // Upload a blob from a buffer or a file (monolithic upload: POST to open the
211
+ // session, PUT the content with its digest to commit it).
212
+ async uploadBlob(digest, size, content) {
213
+ if (await this.blobExists(digest)) {
214
+ return;
215
+ }
216
+ const started = await this.request('POST', 'blobs/uploads/', { redirect: 'manual' });
217
+ if (started.status !== 202) {
218
+ return this.fail(started, 'starting blob upload');
219
+ }
220
+ const location = started.headers.get('location');
221
+ if (!location) {
222
+ throw new RegistryError('registry did not return an upload location');
223
+ }
224
+ const uploadUrl = new URL(location, this.url(''));
225
+ uploadUrl.searchParams.set('digest', digest);
226
+ const response = await this.request('PUT', uploadUrl.toString(), {
227
+ headers: { 'content-type': 'application/octet-stream', 'content-length': `${size}` },
228
+ body: () => ('buffer' in content ? content.buffer : (0, fs_1.createReadStream)(content.file)),
229
+ });
230
+ if (response.status !== 201) {
231
+ return this.fail(response, `uploading blob ${digest}`);
232
+ }
233
+ }
234
+ // Download a blob to a file, verifying its digest while streaming.
235
+ async downloadBlob(descriptor, file) {
236
+ const response = await this.request('GET', `blobs/${descriptor.digest}`);
237
+ if (!response.ok || !response.body) {
238
+ return this.fail(response, `downloading blob ${descriptor.digest}`);
239
+ }
240
+ const hash = (0, crypto_1.createHash)('sha256');
241
+ await (0, promises_2.pipeline)(stream_1.Readable.fromWeb(response.body), new stream_1.Transform({
242
+ transform(chunk, _encoding, callback) {
243
+ hash.update(chunk);
244
+ callback(null, chunk);
245
+ },
246
+ }), (0, fs_1.createWriteStream)(file));
247
+ const actual = `sha256:${hash.digest('hex')}`;
248
+ if (actual !== descriptor.digest) {
249
+ throw new RegistryError(`blob digest mismatch: expected ${descriptor.digest}, got ${actual}`);
250
+ }
251
+ }
252
+ }
253
+ exports.RegistryClient = RegistryClient;
254
+ async function digestFile(file) {
255
+ const hash = (0, crypto_1.createHash)('sha256');
256
+ await (0, promises_2.pipeline)((0, fs_1.createReadStream)(file), hash);
257
+ return { digest: `sha256:${hash.digest('hex')}`, size: (await (0, promises_1.stat)(file)).size };
258
+ }
259
+ exports.digestFile = digestFile;
260
+ function digestBuffer(buffer) {
261
+ return { digest: `sha256:${(0, crypto_1.createHash)('sha256').update(buffer).digest('hex')}`, size: buffer.length };
262
+ }
263
+ exports.digestBuffer = digestBuffer;
264
+ //# sourceMappingURL=registry-client.js.map
@@ -0,0 +1,81 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveRegistryCredentials = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const os_1 = require("os");
6
+ const path_1 = require("path");
7
+ async function readDockerConfig(environment) {
8
+ const dir = environment.processEnvs.DOCKER_CONFIG ?? (0, path_1.join)((0, os_1.homedir)(), '.docker');
9
+ const path = (0, path_1.join)(dir, 'config.json');
10
+ if (!(await environment.file.exists(path))) {
11
+ return null;
12
+ }
13
+ try {
14
+ return JSON.parse(await environment.file.read(path));
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ // `docker-credential-<helper> get` reads the server on stdin and prints
21
+ // {"Username": ..., "Secret": ...}; a missing entry exits non-zero.
22
+ function runCredentialHelper(helper, host) {
23
+ return new Promise((resolve) => {
24
+ let stdout = '';
25
+ let child;
26
+ try {
27
+ child = (0, child_process_1.spawn)(`docker-credential-${helper}`, ['get'], { stdio: ['pipe', 'pipe', 'ignore'] });
28
+ }
29
+ catch {
30
+ resolve(null);
31
+ return;
32
+ }
33
+ child.on('error', () => resolve(null));
34
+ child.stdout.on('data', (chunk) => (stdout += chunk.toString()));
35
+ child.on('close', (code) => {
36
+ if (code !== 0) {
37
+ resolve(null);
38
+ return;
39
+ }
40
+ try {
41
+ const parsed = JSON.parse(stdout);
42
+ resolve(parsed.Secret ? { username: parsed.Username ?? '', password: parsed.Secret } : null);
43
+ }
44
+ catch {
45
+ resolve(null);
46
+ }
47
+ });
48
+ child.stdin.end(host);
49
+ });
50
+ }
51
+ // Resolve credentials for a registry host from the docker config that
52
+ // `docker login` (and CI login actions such as docker/login-action) write, so
53
+ // the cache introduces no credential surface of its own. Order matches docker: credHelpers, then inline auths, then the
54
+ // global credsStore.
55
+ async function resolveRegistryCredentials(credentialHost, environment) {
56
+ const config = await readDockerConfig(environment);
57
+ if (!config) {
58
+ return null;
59
+ }
60
+ const helper = config.credHelpers?.[credentialHost];
61
+ if (helper) {
62
+ return runCredentialHelper(helper, credentialHost);
63
+ }
64
+ const inline = config.auths?.[credentialHost] ?? config.auths?.[`https://${credentialHost}`];
65
+ if (inline?.auth) {
66
+ const decoded = Buffer.from(inline.auth, 'base64').toString('utf8');
67
+ const separator = decoded.indexOf(':');
68
+ if (separator > 0) {
69
+ return { username: decoded.substring(0, separator), password: decoded.substring(separator + 1) };
70
+ }
71
+ }
72
+ if (inline?.username && inline.password) {
73
+ return { username: inline.username, password: inline.password };
74
+ }
75
+ if (config.credsStore) {
76
+ return runCredentialHelper(config.credsStore, credentialHost);
77
+ }
78
+ return null;
79
+ }
80
+ exports.resolveRegistryCredentials = resolveRegistryCredentials;
81
+ //# sourceMappingURL=registry-credentials.js.map
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseRegistryReference = void 0;
4
+ const DOCKER_HUB_HOST = 'registry-1.docker.io';
5
+ const DOCKER_HUB_CREDENTIAL_HOST = 'https://index.docker.io/v1/';
6
+ function isLocalHost(host) {
7
+ const name = host.replace(/:\d+$/, '');
8
+ return name === 'localhost' || name === '127.0.0.1' || name === '[::1]';
9
+ }
10
+ // Parse a repository reference the way docker does: the first path component is
11
+ // a registry host when it contains a `.` or `:` or is `localhost`; otherwise the
12
+ // repository lives on Docker Hub (with the implicit `library/` namespace).
13
+ function parseRegistryReference(value, insecure) {
14
+ const trimmed = value.replace(/^[a-z]+:\/\//, '').replace(/\/+$/, '');
15
+ if (trimmed.includes('@') || /:[^/]*$/.test(trimmed.split('/').slice(1).join('/'))) {
16
+ throw new Error(`registry cache repository "${value}" must not contain a tag or digest`);
17
+ }
18
+ const [first, ...rest] = trimmed.split('/');
19
+ const hasHost = rest.length > 0 && (first.includes('.') || first.includes(':') || first === 'localhost');
20
+ if (!hasHost) {
21
+ const repository = rest.length === 0 ? `library/${first}` : trimmed;
22
+ return { host: DOCKER_HUB_HOST, repository, credentialHost: DOCKER_HUB_CREDENTIAL_HOST, insecure: false };
23
+ }
24
+ const host = first === 'docker.io' || first === 'index.docker.io' ? DOCKER_HUB_HOST : first;
25
+ if (rest.length === 0) {
26
+ throw new Error(`registry cache repository "${value}" has no repository path`);
27
+ }
28
+ return {
29
+ host,
30
+ repository: rest.join('/'),
31
+ credentialHost: host === DOCKER_HUB_HOST ? DOCKER_HUB_CREDENTIAL_HOST : host,
32
+ insecure: insecure ?? isLocalHost(host),
33
+ };
34
+ }
35
+ exports.parseRegistryReference = parseRegistryReference;
36
+ //# sourceMappingURL=registry-reference.js.map