alchemy 0.40.0 → 0.41.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 (71) hide show
  1. package/lib/build-date.d.ts +1 -1
  2. package/lib/build-date.js +1 -1
  3. package/lib/cloudflare/bindings.d.ts +2 -1
  4. package/lib/cloudflare/bindings.d.ts.map +1 -1
  5. package/lib/cloudflare/bindings.js.map +1 -1
  6. package/lib/cloudflare/bound.d.ts +2 -1
  7. package/lib/cloudflare/bound.d.ts.map +1 -1
  8. package/lib/cloudflare/bundle/bundle-worker-dev.d.ts.map +1 -1
  9. package/lib/cloudflare/bundle/bundle-worker-dev.js +2 -1
  10. package/lib/cloudflare/bundle/bundle-worker-dev.js.map +1 -1
  11. package/lib/cloudflare/bundle/bundle-worker.d.ts.map +1 -1
  12. package/lib/cloudflare/bundle/bundle-worker.js +2 -1
  13. package/lib/cloudflare/bundle/bundle-worker.js.map +1 -1
  14. package/lib/cloudflare/container.d.ts +509 -0
  15. package/lib/cloudflare/container.d.ts.map +1 -0
  16. package/lib/cloudflare/container.js +274 -0
  17. package/lib/cloudflare/container.js.map +1 -0
  18. package/lib/cloudflare/index.d.ts +1 -0
  19. package/lib/cloudflare/index.d.ts.map +1 -1
  20. package/lib/cloudflare/index.js +1 -0
  21. package/lib/cloudflare/index.js.map +1 -1
  22. package/lib/cloudflare/worker/miniflare-worker-options.d.ts.map +1 -1
  23. package/lib/cloudflare/worker/miniflare-worker-options.js +28 -0
  24. package/lib/cloudflare/worker/miniflare-worker-options.js.map +1 -1
  25. package/lib/cloudflare/worker-metadata.d.ts +3 -0
  26. package/lib/cloudflare/worker-metadata.d.ts.map +1 -1
  27. package/lib/cloudflare/worker-metadata.js +22 -3
  28. package/lib/cloudflare/worker-metadata.js.map +1 -1
  29. package/lib/cloudflare/worker.d.ts +2 -1
  30. package/lib/cloudflare/worker.d.ts.map +1 -1
  31. package/lib/cloudflare/worker.js +39 -3
  32. package/lib/cloudflare/worker.js.map +1 -1
  33. package/lib/cloudflare/wrangler.json.js +11 -0
  34. package/lib/cloudflare/wrangler.json.js.map +1 -1
  35. package/lib/docker/api.d.ts +15 -0
  36. package/lib/docker/api.d.ts.map +1 -1
  37. package/lib/docker/api.js +59 -0
  38. package/lib/docker/api.js.map +1 -1
  39. package/lib/docker/image.d.ts +22 -4
  40. package/lib/docker/image.d.ts.map +1 -1
  41. package/lib/docker/image.js +68 -24
  42. package/lib/docker/image.js.map +1 -1
  43. package/lib/github/index.d.ts +1 -0
  44. package/lib/github/index.d.ts.map +1 -1
  45. package/lib/github/index.js +1 -0
  46. package/lib/github/index.js.map +1 -1
  47. package/lib/github/repository-webhook.d.ts +123 -0
  48. package/lib/github/repository-webhook.d.ts.map +1 -0
  49. package/lib/github/repository-webhook.js +146 -0
  50. package/lib/github/repository-webhook.js.map +1 -0
  51. package/lib/scope.d.ts +2 -0
  52. package/lib/scope.d.ts.map +1 -1
  53. package/lib/scope.js +4 -0
  54. package/lib/scope.js.map +1 -1
  55. package/package.json +3 -2
  56. package/src/build-date.ts +1 -1
  57. package/src/cloudflare/bindings.ts +2 -0
  58. package/src/cloudflare/bound.ts +9 -1
  59. package/src/cloudflare/bundle/bundle-worker-dev.ts +2 -1
  60. package/src/cloudflare/bundle/bundle-worker.ts +2 -1
  61. package/src/cloudflare/container.ts +843 -0
  62. package/src/cloudflare/index.ts +1 -0
  63. package/src/cloudflare/worker/miniflare-worker-options.ts +28 -0
  64. package/src/cloudflare/worker-metadata.ts +28 -6
  65. package/src/cloudflare/worker.ts +67 -16
  66. package/src/cloudflare/wrangler.json.ts +12 -0
  67. package/src/docker/api.ts +74 -0
  68. package/src/docker/image.ts +99 -29
  69. package/src/github/index.ts +1 -0
  70. package/src/github/repository-webhook.ts +269 -0
  71. package/src/scope.ts +5 -0
@@ -13,6 +13,7 @@ export * from "./browser-rendering.ts";
13
13
  export * from "./bucket.ts";
14
14
  export * from "./bundle/external.ts";
15
15
  export * from "./bundle/local-dev-cloudflare-shim.ts";
16
+ export * from "./container.ts";
16
17
  export * from "./custom-domain.ts";
17
18
  export * from "./d1-clone.ts";
18
19
  export * from "./d1-database.ts";
@@ -225,6 +225,11 @@ export function buildMiniflareWorkerOptions({
225
225
  compatibilityDate,
226
226
  compatibilityFlags,
227
227
  unsafeDirectSockets: [{ entrypoint: undefined, proxy: true }],
228
+ // containerEngine: {
229
+ // localDocker: {
230
+ // socketPath: "/var/run/docker.sock",
231
+ // }
232
+ // }
228
233
  };
229
234
  for (const [name, binding] of Object.entries(bindings ?? {})) {
230
235
  if (typeof binding === "string") {
@@ -513,6 +518,29 @@ export function buildMiniflareWorkerOptions({
513
518
  };
514
519
  break;
515
520
  }
521
+ case "container": {
522
+ (options.durableObjects ??= {})[name] = {
523
+ className: binding.className,
524
+ scriptName: binding.scriptName,
525
+ useSQLite: binding.sqlite,
526
+ container: {
527
+ imageName: binding.image.name,
528
+ },
529
+ // namespaceId: binding.namespaceId,
530
+ // unsafeUniqueKey?: string | typeof kUnsafeEphemeralUniqueKey | undefined;
531
+ // unsafePreventEviction?: boolean | undefined;
532
+ // remoteProxyConnectionString: binding.local
533
+ // ? undefined
534
+ // : remoteProxyConnectionString,
535
+ };
536
+ if (!binding.scriptName || binding.scriptName === workerName) {
537
+ options.unsafeDirectSockets!.push({
538
+ entrypoint: binding.className,
539
+ proxy: true,
540
+ });
541
+ }
542
+ break;
543
+ }
516
544
  default: {
517
545
  assertNever(binding);
518
546
  }
@@ -8,6 +8,7 @@ import {
8
8
  type WorkerBindingDurableObjectNamespace,
9
9
  type WorkerBindingSpec,
10
10
  } from "./bindings.ts";
11
+ import { isContainer, type Container } from "./container.ts";
11
12
  import {
12
13
  isDurableObjectNamespace,
13
14
  type DurableObjectNamespace,
@@ -194,6 +195,7 @@ export interface WorkerMetadata {
194
195
  cron: string;
195
196
  suspended: boolean;
196
197
  }[];
198
+ containers?: { class_name: string }[];
197
199
  }
198
200
 
199
201
  export async function prepareWorkerMetadata(
@@ -207,8 +209,12 @@ export async function prepareWorkerMetadata(
207
209
  },
208
210
  ): Promise<WorkerMetadata> {
209
211
  const oldSettings = await getWorkerSettings(api, props.workerName);
210
- const oldTags: string[] | undefined =
211
- oldSettings?.default_environment?.script?.tags;
212
+ const oldTags: string[] | undefined = Array.from(
213
+ new Set([
214
+ ...(oldSettings?.default_environment?.script?.tags ?? []),
215
+ ...(oldSettings?.tags ?? []),
216
+ ]),
217
+ );
212
218
  const oldBindings = oldSettings?.bindings;
213
219
  // we use Cloudflare Worker tags to store a mapping between Alchemy's stable identifier and the binding name
214
220
  // e.g.
@@ -254,8 +260,9 @@ export async function prepareWorkerMetadata(
254
260
 
255
261
  // try and find the DO binding by stable id
256
262
  const object = Object.values(props.bindings).find(
257
- (binding): binding is DurableObjectNamespace<any> =>
258
- isDurableObjectNamespace(binding) && binding.id === stableId,
263
+ (binding): binding is DurableObjectNamespace | Container =>
264
+ (isDurableObjectNamespace(binding) || isContainer(binding)) &&
265
+ binding.id === stableId,
259
266
  );
260
267
  if (object) {
261
268
  // we found the corresponding object, it should not be deleted
@@ -302,7 +309,7 @@ export async function prepareWorkerMetadata(
302
309
  // we use this to reliably compute class migrations based on server-side state
303
310
  ...Object.entries(props.bindings ?? {}).flatMap(
304
311
  ([bindingName, binding]) =>
305
- isDurableObjectNamespace(binding)
312
+ isDurableObjectNamespace(binding) || isContainer(binding)
306
313
  ? // TODO(sam): base64 encode if contains `:`?
307
314
  [`alchemy:do:${binding.id}:${bindingName}`]
308
315
  : [],
@@ -512,6 +519,21 @@ export async function prepareWorkerMetadata(
512
519
  key_base64: binding.key_base64?.unencrypted,
513
520
  key_jwk: binding.key_jwk?.unencrypted,
514
521
  });
522
+ } else if (binding.type === "container") {
523
+ meta.bindings.push({
524
+ type: "durable_object_namespace",
525
+ class_name: binding.className,
526
+ name: bindingName,
527
+ });
528
+ if (
529
+ binding.scriptName === undefined ||
530
+ // TODO(sam): not sure if Cloudflare Api would like us using `this` worker name here
531
+ binding.scriptName === props.workerName
532
+ ) {
533
+ // we do not need configure class migrations for cross-script bindings
534
+ configureClassMigration(bindingName, binding);
535
+ }
536
+ (meta.containers ??= []).push({ class_name: binding.className });
515
537
  } else {
516
538
  assertNever(
517
539
  binding,
@@ -525,7 +547,7 @@ export async function prepareWorkerMetadata(
525
547
 
526
548
  function configureClassMigration(
527
549
  bindingName: string,
528
- newBinding: DurableObjectNamespace<any>,
550
+ newBinding: DurableObjectNamespace<any> | Container,
529
551
  ) {
530
552
  let prevBinding: WorkerBindingDurableObjectNamespace | undefined;
531
553
  if (oldBindings) {
@@ -33,7 +33,9 @@ import {
33
33
  type Binding,
34
34
  type Bindings,
35
35
  Json,
36
+ type WorkerBindingDurableObjectNamespace,
36
37
  type WorkerBindingService,
38
+ type WorkerBindingSpec,
37
39
  } from "./bindings.ts";
38
40
  import type { Bound } from "./bound.ts";
39
41
  import { isBucket } from "./bucket.ts";
@@ -42,6 +44,7 @@ import {
42
44
  type NoBundleResult,
43
45
  bundleWorkerScript,
44
46
  } from "./bundle/bundle-worker.ts";
47
+ import { type Container, ContainerApplication } from "./container.ts";
45
48
  import { CustomDomain } from "./custom-domain.ts";
46
49
  import { isD1Database } from "./d1-database.ts";
47
50
  import type { DispatchNamespaceResource } from "./dispatch-namespace.ts";
@@ -1176,6 +1179,7 @@ export const _Worker = Resource(
1176
1179
  // Find any assets bindings
1177
1180
  const assetsBindings: { name: string; assets: Assets }[] = [];
1178
1181
  const workflowsBindings: Workflow[] = [];
1182
+ const containersBindings: Container[] = [];
1179
1183
 
1180
1184
  if (props.bindings) {
1181
1185
  for (const [bindingName, binding] of Object.entries(props.bindings)) {
@@ -1184,6 +1188,8 @@ export const _Worker = Resource(
1184
1188
  assetsBindings.push({ name: bindingName, assets: binding });
1185
1189
  } else if (binding.type === "workflow") {
1186
1190
  workflowsBindings.push(binding);
1191
+ } else if (binding.type === "container") {
1192
+ containersBindings.push(binding);
1187
1193
  }
1188
1194
  }
1189
1195
  }
@@ -1223,6 +1229,7 @@ export const _Worker = Resource(
1223
1229
  compatibilityFlags,
1224
1230
  assetUploadResult,
1225
1231
  });
1232
+ versionResult.versionId;
1226
1233
 
1227
1234
  for (const workflow of workflowsBindings) {
1228
1235
  if (
@@ -1237,6 +1244,47 @@ export const _Worker = Resource(
1237
1244
  }
1238
1245
  }
1239
1246
 
1247
+ for (const container of containersBindings) {
1248
+ async function findNamespaceId() {
1249
+ const response = await api.get(
1250
+ `/accounts/${api.accountId}/workers/scripts/${workerName}/versions/${versionResult.deploymentId}`,
1251
+ );
1252
+ const result = (await response.json()) as {
1253
+ result: {
1254
+ resources: {
1255
+ bindings: WorkerBindingSpec[];
1256
+ };
1257
+ };
1258
+ };
1259
+ const namespaceId = result.result.resources.bindings.find(
1260
+ (binding): binding is WorkerBindingDurableObjectNamespace =>
1261
+ binding.type === "durable_object_namespace" &&
1262
+ binding.class_name === container.className,
1263
+ )?.namespace_id;
1264
+ if (!namespaceId) {
1265
+ throw new Error(
1266
+ `Namespace ID not found for container ${container.id}`,
1267
+ );
1268
+ }
1269
+ return namespaceId;
1270
+ }
1271
+ await ContainerApplication(container.id, {
1272
+ accountId: props.accountId,
1273
+ apiKey: props.apiKey,
1274
+ apiToken: props.apiToken,
1275
+ baseUrl: props.baseUrl,
1276
+ email: props.email,
1277
+ image: container.image,
1278
+ name: container.id,
1279
+ instanceType: container.instanceType,
1280
+ observability: container.observability,
1281
+ durableObjects: {
1282
+ namespaceId: await findNamespaceId(),
1283
+ },
1284
+ schedulingPolicy: container.schedulingPolicy,
1285
+ });
1286
+ }
1287
+
1240
1288
  await Promise.all(
1241
1289
  props.eventSources?.map((eventSource) => {
1242
1290
  if (isQueue(eventSource) || isQueueEventSource(eventSource)) {
@@ -1582,7 +1630,7 @@ type PutWorkerOptions = WorkerProps & {
1582
1630
  export async function putWorker(
1583
1631
  api: CloudflareApi,
1584
1632
  props: PutWorkerOptions,
1585
- ): Promise<{ versionId?: string; previewUrl?: string }> {
1633
+ ): Promise<{ versionId?: string; previewUrl?: string; deploymentId: string }> {
1586
1634
  const {
1587
1635
  //
1588
1636
  dispatchNamespace,
@@ -1727,23 +1775,23 @@ export async function putWorker(
1727
1775
  }
1728
1776
  }
1729
1777
  }
1730
-
1731
- // Handle version response
1732
- if (props.version) {
1733
- const responseData = (await uploadResponse.json()) as {
1734
- result: {
1735
- id: string;
1736
- number: number;
1737
- metadata: {
1738
- has_preview: boolean;
1739
- };
1740
- annotations?: {
1741
- "workers/tag"?: string;
1742
- };
1778
+ const responseData = (await uploadResponse.json()) as {
1779
+ result: {
1780
+ id: string;
1781
+ number: number;
1782
+ metadata: {
1783
+ has_preview: boolean;
1743
1784
  };
1785
+ annotations?: {
1786
+ "workers/tag"?: string;
1787
+ };
1788
+ deployment_id: string;
1744
1789
  };
1745
- const result = responseData.result;
1790
+ };
1791
+ const result = responseData.result;
1746
1792
 
1793
+ // Handle version response
1794
+ if (props.version) {
1747
1795
  // Get the account's workers.dev subdomain to construct preview URL
1748
1796
  let previewUrl: string | undefined;
1749
1797
  if (result.metadata?.has_preview) {
@@ -1770,10 +1818,13 @@ export async function putWorker(
1770
1818
  return {
1771
1819
  versionId: result.id,
1772
1820
  previewUrl,
1821
+ deploymentId: result.deployment_id,
1773
1822
  };
1774
1823
  }
1775
1824
 
1776
- return {};
1825
+ return {
1826
+ deploymentId: result.deployment_id,
1827
+ };
1777
1828
  },
1778
1829
  (err) =>
1779
1830
  err.status === 404 ||
@@ -447,6 +447,9 @@ function processBindings(
447
447
  binding: string;
448
448
  namespace: string;
449
449
  }[] = [];
450
+ const containers: {
451
+ class_name: string;
452
+ }[] = [];
450
453
 
451
454
  for (const eventSource of eventSources ?? []) {
452
455
  if (isQueueEventSource(eventSource)) {
@@ -619,6 +622,15 @@ function processBindings(
619
622
  });
620
623
  } else if (binding.type === "secret_key") {
621
624
  // no-op
625
+ } else if (binding.type === "container") {
626
+ durableObjects.push({
627
+ name: bindingName,
628
+ class_name: binding.className,
629
+ script_name: binding.scriptName,
630
+ });
631
+ containers.push({
632
+ class_name: binding.className,
633
+ });
622
634
  } else {
623
635
  // biome-ignore lint/correctness/noVoidTypeReturn: it returns never
624
636
  return assertNever(binding);
package/src/docker/api.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { exec } from "../os/exec.ts";
2
3
 
3
4
  /**
@@ -362,4 +363,77 @@ export class DockerApi {
362
363
  return false;
363
364
  }
364
365
  }
366
+
367
+ /**
368
+ * Login to a Docker registry
369
+ *
370
+ * @param registry Registry URL
371
+ * @param username Username for authentication
372
+ * @param password Password for authentication
373
+ * @returns Promise that resolves when login is successful
374
+ */
375
+ async login(
376
+ registry: string,
377
+ username: string,
378
+ password: string,
379
+ ): Promise<void> {
380
+ return new Promise((resolve, reject) => {
381
+ const args = [
382
+ "login",
383
+ registry,
384
+ "--username",
385
+ username,
386
+ "--password-stdin",
387
+ ];
388
+
389
+ const child = spawn(this.dockerPath, args, {
390
+ stdio: ["pipe", "pipe", "pipe"],
391
+ });
392
+
393
+ let stdout = "";
394
+ let stderr = "";
395
+
396
+ child.stdout.on("data", (data) => {
397
+ stdout += data.toString();
398
+ });
399
+
400
+ child.stderr.on("data", (data) => {
401
+ stderr += data.toString();
402
+ });
403
+
404
+ child.on("close", (code) => {
405
+ if (code === 0) {
406
+ resolve();
407
+ } else {
408
+ reject(
409
+ new Error(
410
+ `Docker login failed with exit code ${code}: ${stderr || stdout}`,
411
+ ),
412
+ );
413
+ }
414
+ });
415
+
416
+ child.on("error", (err) => {
417
+ reject(new Error(`Docker login failed: ${err.message}`));
418
+ });
419
+
420
+ // Write password to stdin and close the stream
421
+ child.stdin.write(password);
422
+ child.stdin.end();
423
+ });
424
+ }
425
+
426
+ /**
427
+ * Logout from a Docker registry
428
+ *
429
+ * @param registry Registry URL
430
+ */
431
+ async logout(registry: string): Promise<void> {
432
+ try {
433
+ await this.exec(["logout", registry]);
434
+ } catch (error) {
435
+ // Ignore logout errors as they're not critical
436
+ console.warn(`Docker logout failed: ${error}`);
437
+ }
438
+ }
365
439
  }
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import type { Context } from "../context.ts";
4
4
  import { Resource } from "../resource.ts";
5
+ import type { Secret } from "../secret.ts";
5
6
  import { DockerApi } from "./api.ts";
6
7
 
7
8
  /**
@@ -10,11 +11,15 @@ import { DockerApi } from "./api.ts";
10
11
  export interface DockerBuildOptions {
11
12
  /**
12
13
  * Path to the build context directory
14
+ *
15
+ * @default - the `dirname(dockerfile)` if provided or otherwise `process.cwd()`
13
16
  */
14
- context: string;
17
+ context?: string;
15
18
 
16
19
  /**
17
20
  * Path to the Dockerfile, relative to context
21
+ *
22
+ * @default - `Dockerfile`
18
23
  */
19
24
  dockerfile?: string;
20
25
 
@@ -39,6 +44,12 @@ export interface DockerBuildOptions {
39
44
  cacheFrom?: string[];
40
45
  }
41
46
 
47
+ export interface ImageRegistry {
48
+ username: string;
49
+ password: Secret;
50
+ server: string;
51
+ }
52
+
42
53
  /**
43
54
  * Properties for creating a Docker image
44
55
  */
@@ -46,7 +57,7 @@ export interface ImageProps {
46
57
  /**
47
58
  * Repository name for the image (e.g., "username/image")
48
59
  */
49
- name: string;
60
+ name?: string;
50
61
 
51
62
  /**
52
63
  * Tag for the image (e.g., "latest")
@@ -56,7 +67,12 @@ export interface ImageProps {
56
67
  /**
57
68
  * Build configuration
58
69
  */
59
- build: DockerBuildOptions;
70
+ build?: DockerBuildOptions;
71
+
72
+ /**
73
+ * Registry credentials
74
+ */
75
+ registry?: ImageRegistry;
60
76
 
61
77
  /**
62
78
  * Whether to skip pushing the image to registry
@@ -68,6 +84,11 @@ export interface ImageProps {
68
84
  * Docker Image resource
69
85
  */
70
86
  export interface Image extends Resource<"docker::Image">, ImageProps {
87
+ /**
88
+ * Image name
89
+ */
90
+ name: string;
91
+
71
92
  /**
72
93
  * Full image reference (name:tag)
73
94
  */
@@ -110,7 +131,7 @@ export const Image = Resource(
110
131
  "docker::Image",
111
132
  async function (
112
133
  this: Context<Image>,
113
- _id: string,
134
+ id: string,
114
135
  props: ImageProps,
115
136
  ): Promise<Image> {
116
137
  // Initialize Docker API client
@@ -123,34 +144,44 @@ export const Image = Resource(
123
144
  } else {
124
145
  // Normalize properties
125
146
  const tag = props.tag || "latest";
126
- const imageRef = `${props.name}:${tag}`;
147
+ const name = props.name || id;
148
+ const imageRef = `${name}:${tag}`;
127
149
 
128
- // Validate build context
129
- const { context } = props.build;
150
+ let context: string;
151
+ let dockerfile: string;
152
+ if (props.build?.dockerfile && props.build?.context) {
153
+ context = path.resolve(props.build.context);
154
+ dockerfile = path.resolve(context, props.build.dockerfile);
155
+ } else if (props.build?.dockerfile) {
156
+ context = process.cwd();
157
+ dockerfile = path.resolve(context, props.build.dockerfile);
158
+ } else if (props.build?.context) {
159
+ context = path.resolve(props.build.context);
160
+ dockerfile = path.resolve(context, "Dockerfile");
161
+ } else {
162
+ context = process.cwd();
163
+ dockerfile = path.resolve(context, "Dockerfile");
164
+ }
130
165
  await fs.access(context);
131
-
132
- // Determine Dockerfile path
133
- const dockerfile = props.build.dockerfile || "Dockerfile";
134
- const dockerfilePath = path.join(context, dockerfile);
135
- await fs.access(dockerfilePath);
166
+ await fs.access(dockerfile);
136
167
 
137
168
  // Prepare build options
138
- const buildOptions: Record<string, string> = props.build.buildArgs || {};
169
+ const buildOptions: Record<string, string> = props.build?.buildArgs || {};
139
170
 
140
171
  // Add platform if specified
141
172
  let buildArgs = ["build", "-t", imageRef];
142
173
 
143
- if (props.build.platform) {
174
+ if (props.build?.platform) {
144
175
  buildArgs.push("--platform", props.build.platform);
145
176
  }
146
177
 
147
178
  // Add target if specified
148
- if (props.build.target) {
179
+ if (props.build?.target) {
149
180
  buildArgs.push("--target", props.build.target);
150
181
  }
151
182
 
152
183
  // Add cache sources if specified
153
- if (props.build.cacheFrom && props.build.cacheFrom.length > 0) {
184
+ if (props.build?.cacheFrom && props.build.cacheFrom.length > 0) {
154
185
  for (const cacheSource of props.build.cacheFrom) {
155
186
  buildArgs.push("--cache-from", cacheSource);
156
187
  }
@@ -161,36 +192,75 @@ export const Image = Resource(
161
192
  buildArgs.push("--build-arg", `${key}="${value}"`);
162
193
  }
163
194
 
164
- // Add dockerfile if not the default
165
- if (props.build.dockerfile && props.build.dockerfile !== "Dockerfile") {
166
- buildArgs.push("-f", props.build.dockerfile);
167
- }
195
+ buildArgs.push("-f", dockerfile);
168
196
 
169
197
  // Add context path
170
- buildArgs.push(props.build.context);
198
+ buildArgs.push(context);
171
199
 
172
200
  // Execute build command
173
- console.log(`Building Docker image: ${imageRef}`);
174
201
  const { stdout } = await api.exec(buildArgs);
175
202
 
176
203
  // Extract image ID from build output if available
177
204
  const imageIdMatch = /Successfully built ([a-f0-9]+)/.exec(stdout);
178
205
  const imageId = imageIdMatch ? imageIdMatch[1] : undefined;
179
206
 
180
- console.log(`Successfully built Docker image: ${imageRef}`);
181
-
182
207
  // Handle push if required
183
208
  let repoDigest: string | undefined;
184
- if (!props.skipPush) {
185
- console.log(`Pushing Docker image: ${imageRef}`);
186
- // TODO: Implement push once API supports it
187
- console.warn("Image pushing is not yet implemented");
209
+ let finalImageRef = imageRef;
210
+ if (props.registry && !props.skipPush) {
211
+ const { server, username, password } = props.registry;
212
+
213
+ // Ensure the registry server does not have trailing slash
214
+ const registryHost = server.replace(/\/$/, "");
215
+
216
+ // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
217
+ const firstSegment = imageRef.split("/")[0];
218
+ const hasRegistryPrefix = firstSegment.includes(".");
219
+
220
+ // Compose the target image reference that will be pushed
221
+ const targetImage = hasRegistryPrefix
222
+ ? imageRef // already fully-qualified
223
+ : `${registryHost}/${imageRef}`;
224
+
225
+ try {
226
+ // Authenticate to registry
227
+ await api.login(registryHost, username, password.unencrypted);
228
+
229
+ // Tag local image with fully qualified name if necessary
230
+ if (targetImage !== imageRef) {
231
+ await api.exec(["tag", imageRef, targetImage]);
232
+ }
233
+
234
+ // Push the image
235
+ const { stdout: pushOut } = await api.exec(["push", targetImage]);
236
+
237
+ // Attempt to extract the repo digest from push output
238
+ const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(
239
+ pushOut,
240
+ );
241
+ if (digestMatch) {
242
+ const digestHash = digestMatch[1];
243
+ // Strip tag (anything after last :) to build image@digest reference
244
+ const [repoWithoutTag] =
245
+ targetImage.split(":").length > 2
246
+ ? [targetImage] // unlikely but safety
247
+ : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
248
+ repoDigest = `${repoWithoutTag}@${digestHash}`;
249
+ }
250
+
251
+ // Update the final image reference to point at the pushed image
252
+ finalImageRef = targetImage;
253
+ } finally {
254
+ // Always try to logout – failures are non-fatal
255
+ await api.logout(registryHost);
256
+ }
188
257
  }
189
258
 
190
259
  // Return the resource using this() to construct output
191
260
  return this({
192
261
  ...props,
193
- imageRef,
262
+ name,
263
+ imageRef: finalImageRef,
194
264
  imageId,
195
265
  repoDigest,
196
266
  builtAt: Date.now(),
@@ -1,3 +1,4 @@
1
1
  export * from "./comment.ts";
2
2
  export * from "./repository-environment.ts";
3
+ export * from "./repository-webhook.ts";
3
4
  export * from "./secret.ts";