diff --git a/.claude/tasks/ideation/publishable-resources.md b/.claude/tasks/ideation/publishable-resources.md
new file mode 100644
index 0000000..56ccd90
--- /dev/null
+++ b/.claude/tasks/ideation/publishable-resources.md
@@ -0,0 +1,234 @@
+# Resource Publishing Abstraction - Ideation
+
+## Current State Analysis
+
+### The Good News
+
+The foundation for publishable resources **already exists** in the codebase. The `IResourceBuilder` interface at `src/monorepo/resources/types.ts:28` already defines:
+
+```typescript
+publish?(resource: ResourceInfo, out?: Writable): Promise;
+```
+
+This optional method is part of the resource builder contract but is **not currently used** by any resource type.
+
+### The Problem
+
+Despite this interface existing, image publishing is handled through a separate, Docker-specific path:
+
+1. `emb images push` command (`src/cli/commands/images/push.ts`)
+2. `PushImagesOperation` (`src/docker/operations/images/PushImagesOperation.ts`)
+3. Hardcoded filter: `monorepo.resources.filter((r) => r.type === 'docker/image')`
+
+This breaks the resource abstraction. Resources are "buildable" generically via `emb resources build`, but "publishable" only through type-specific commands.
+
+## Proposed Design
+
+### 1. Implement `publish()` in Resource Builders
+
+Move the push logic from `PushImagesOperation` into `DockerImageResourceBuilder.publish()`:
+
+```typescript
+// DockerImageResource.ts
+class DockerImageResourceBuilder extends SentinelFileBasedBuilder<...> {
+ async publish(resource: ResourceInfo, out?: Writable): Promise {
+ const reference = await this.getReference();
+ const image = docker.getImage(reference);
+
+ // Merge defaults with resource-specific config (resource wins)
+ const defaults = this.monorepo.defaults.docker?.publish;
+ const resourceConfig = resource.params?.publish;
+ const publishConfig = {
+ registry: resourceConfig?.registry ?? defaults?.registry,
+ tag: resourceConfig?.tag ?? defaults?.tag,
+ };
+
+ // Retag if registry or tag override specified
+ if (publishConfig.registry || publishConfig.tag) {
+ await this.retag(reference, publishConfig);
+ }
+
+ const stream = await image.push({ authconfig: ... });
+ await followProgress(stream);
+ }
+}
+```
+
+### 2. Create `PublishResourcesOperation`
+
+Mirror the `BuildResourcesOperation` pattern:
+
+```typescript
+// src/monorepo/operations/resources/PublishResourcesOperation.ts
+export class PublishResourcesOperation extends AbstractOperation {
+ protected async _run(input): Promise {
+ const publishable = monorepo.resources.filter(r => {
+ // Must explicitly opt-in
+ if (r.publish !== true) return false;
+
+ // Builder must support publishing
+ const builder = ResourceFactory.factor(r.type, { config: r, ... });
+ return typeof builder.publish === 'function';
+ });
+
+ // Respect dependencies for publish order
+ const ordered = findRunOrder(input.resources || publishable.map(r => r.id), collection);
+
+ for (const resource of ordered) {
+ const builder = ResourceFactory.factor(resource.type, { ... });
+ await builder.publish(resource, this.out);
+ }
+ }
+}
+```
+
+### 3. Add `emb resources publish` Command
+
+```typescript
+// src/cli/commands/resources/publish.ts
+export default class ResourcesPublishCommand extends FlavoredCommand {
+ static args = {
+ resources: Args.string({ description: 'Resources to publish (defaults to all publishable)' })
+ };
+
+ static flags = {
+ 'dry-run': Flags.boolean({ description: 'Show what would be published' }),
+ };
+
+ async run() {
+ const { monorepo } = getContext();
+ return monorepo.run(new PublishResourcesOperation(process.stdout), {
+ resources: argv.length > 0 ? argv : undefined,
+ dryRun: flags['dry-run'],
+ });
+ }
+}
+```
+
+### 4. Explicit Opt-In for Publishing
+
+Resources must explicitly opt-in to publishing by setting `publish: true`:
+
+```yaml
+components:
+ api:
+ resources:
+ image:
+ type: docker/image
+ publish: true # Opt-in: this image will be published
+ params:
+ image: my-api
+
+ dev-tools:
+ resources:
+ image:
+ type: docker/image
+ # No publish flag: this image won't be published
+ params:
+ image: dev-tools
+```
+
+This means:
+- Resources are **not publishable by default**
+- Set `publish: true` at resource level to include in `emb resources publish`
+- Builder must also implement `publish()` for the resource type to support it
+- `emb resources publish` only processes resources where `publish === true`
+
+### 5. Defaults Configuration
+
+Add `publish` settings to the existing `defaults.docker` section. This follows the current pattern where `defaults.docker.tag`, `defaults.docker.platform`, and `defaults.docker.buildArgs` are already supported.
+
+```yaml
+defaults:
+ docker:
+ tag: latest
+ platform: linux/amd64
+ buildArgs:
+ NODE_ENV: production
+ # NEW: publishing defaults
+ publish:
+ registry: ghcr.io/myorg
+ tag: ${env:VERSION:-latest} # Optional: override tag when publishing
+```
+
+**Precedence order** (most specific wins):
+1. Resource-level `params.publish.*`
+2. Defaults-level `defaults.docker.publish.*`
+3. Built-in defaults (no registry prefix, use build tag)
+
+### 6. Resource-Specific Publish Config
+
+Individual resources can override the defaults:
+
+```yaml
+components:
+ api:
+ resources:
+ image:
+ type: docker/image
+ publish: true # Required to enable publishing
+ params:
+ image: my-api
+ tag: ${env:VERSION:-latest}
+ publish:
+ registry: docker.io/mycompany # Override default registry
+ tag: ${env:RELEASE_TAG} # Override default publish tag
+```
+
+This keeps the command generic while allowing type-specific configuration.
+
+## Benefits
+
+1. **Consistent abstraction**: `build` → `publish` works for all resource types
+2. **Future-proof**: Easy to add new publishable resources:
+ - `npm/package` → publish to npm registry
+ - `s3/artifact` → upload to S3
+ - `helm/chart` → push to chart repository
+ - `oci/artifact` → push to OCI registry
+3. **Plugin-friendly**: Custom resource types can define their own publish behavior
+4. **Simpler CLI**: One command to rule them all
+5. **Composable**: Can publish specific resources by ID or all publishable resources
+
+## Migration Path
+
+1. Keep `emb images push` as an alias (deprecated) for backwards compatibility
+2. It internally calls `emb resources publish` filtered to `docker/image` types
+3. Document the new approach
+4. Remove `emb images push` in a future major version
+
+## Design Decisions
+
+### Publishing Caching
+
+**Decision:** No caching - always publish when asked.
+
+Publishing will not use the sentinel/caching system. When `emb resources publish` is called, it will always push. This matches the current `emb images push` behavior and avoids complexity around tracking remote registry state.
+
+### Docker-Specific Commands
+
+**Decision:** Keep `emb images prune` and `emb images delete` as Docker-specific.
+
+These are management/cleanup commands that don't fit the resource build/publish lifecycle. They remain under `emb images` as Docker-specific utilities.
+
+### Publishable Filter
+
+**Decision:** Add `--publishable` flag to `emb resources list`.
+
+This allows users to see which resources can be published:
+
+```
+$ emb resources list --publishable
+ ID TYPE REFERENCE
+ api:image docker/image myproject/api:latest
+ web:image docker/image myproject/web:latest
+```
+
+## Implementation Order
+
+1. Extend config schema to support `defaults.docker.publish` (registry, tag)
+2. Add `publish()` to `DockerImageResourceBuilder` (reads from defaults + resource params)
+3. Create `PublishResourcesOperation`
+4. Add `emb resources publish` command
+5. Add `--publishable` filter to `emb resources` list
+6. Deprecate `emb images push` (keep working, add deprecation warning)
+7. Update documentation
diff --git a/examples/production-ready/api/Embfile.yml b/examples/production-ready/api/Embfile.yml
index a052f0c..c09773c 100644
--- a/examples/production-ready/api/Embfile.yml
+++ b/examples/production-ready/api/Embfile.yml
@@ -3,6 +3,7 @@ description: Production-ready API with multi-stage builds
resources:
image:
type: docker/image
+ publish: true
params:
target: development
diff --git a/examples/production-ready/web/Embfile.yml b/examples/production-ready/web/Embfile.yml
index 442b9a1..20cb3e6 100644
--- a/examples/production-ready/web/Embfile.yml
+++ b/examples/production-ready/web/Embfile.yml
@@ -3,6 +3,7 @@ description: Production-ready web frontend
resources:
image:
type: docker/image
+ publish: true
params:
target: development
diff --git a/src/cli/commands/images/push.ts b/src/cli/commands/images/push.ts
index 38f0415..0f6143c 100644
--- a/src/cli/commands/images/push.ts
+++ b/src/cli/commands/images/push.ts
@@ -4,7 +4,12 @@ import { FlavoredCommand } from '@/cli';
import { PushImagesOperation } from '@/docker/operations/images/PushImagesOperation.js';
export default class ImagesPush extends FlavoredCommand {
- static description = 'Push docker images.';
+ static deprecationOptions = {
+ message:
+ 'Use "emb resources publish" instead. Configure publishing with publish: true on resources and defaults.docker.publish in .emb.yml',
+ };
+ static description =
+ '[DEPRECATED] Push docker images. Use "emb resources publish" instead.';
static enableJsonFlag = true;
static examples = [
'<%= config.bin %> <%= command.id %>',
diff --git a/src/cli/commands/resources/build.ts b/src/cli/commands/resources/build.ts
index 55247d4..398b109 100644
--- a/src/cli/commands/resources/build.ts
+++ b/src/cli/commands/resources/build.ts
@@ -18,6 +18,7 @@ export default class ResourcesBuildCommand extends FlavoredCommand<
static description = 'Build the resources of the monorepo';
static examples = [
`<%= config.bin %> <%= command.id %> build --flavor development`,
+ `<%= config.bin %> <%= command.id %> build --publishable --flavor production`,
];
static flags = {
'dry-run': Flags.boolean({
@@ -31,6 +32,11 @@ export default class ResourcesBuildCommand extends FlavoredCommand<
required: false,
description: 'Bypass the cache and force the build',
}),
+ publishable: Flags.boolean({
+ required: false,
+ description:
+ 'Only build resources that are publishable (publish: true) and their dependencies',
+ }),
};
static enableJsonFlag = true;
static strict = false;
@@ -39,10 +45,16 @@ export default class ResourcesBuildCommand extends FlavoredCommand<
const { argv, flags } = await this.parse(ResourcesBuildCommand);
const { monorepo } = getContext();
- const toBuild =
- argv.length > 0
- ? (argv as string[])
- : monorepo.resources.map((c) => c.id);
+ let toBuild: string[];
+ if (argv.length > 0) {
+ toBuild = argv as string[];
+ } else if (flags.publishable) {
+ toBuild = monorepo.resources
+ .filter((r) => r.publish === true)
+ .map((r) => r.id);
+ } else {
+ toBuild = monorepo.resources.map((c) => c.id);
+ }
return monorepo.run(new BuildResourcesOperation(), {
dryRun: flags['dry-run'],
diff --git a/src/cli/commands/resources/index.ts b/src/cli/commands/resources/index.ts
index 2c5fcfb..03c221c 100644
--- a/src/cli/commands/resources/index.ts
+++ b/src/cli/commands/resources/index.ts
@@ -1,3 +1,4 @@
+import { Flags } from '@oclif/core';
import { printTable } from '@oclif/table';
import { FlavoredCommand, getContext, TABLE_DEFAULTS } from '@/cli';
@@ -9,15 +10,29 @@ export default class ResourcesIndex extends FlavoredCommand<
> {
static description = 'List resources.';
static enableJsonFlag = true;
- static examples = ['<%= config.bin %> <%= command.id %>'];
- static flags = {};
+ static examples = [
+ '<%= config.bin %> <%= command.id %>',
+ '<%= config.bin %> <%= command.id %> --publishable',
+ ];
+ static flags = {
+ publishable: Flags.boolean({
+ description: 'Only show resources that are publishable (publish: true)',
+ required: false,
+ }),
+ };
public async run(): Promise> {
const { flags } = await this.parse(ResourcesIndex);
const { monorepo } = await getContext();
+ // Filter resources if --publishable flag is set
+ let filteredResources = monorepo.resources;
+ if (flags.publishable) {
+ filteredResources = filteredResources.filter((r) => r.publish === true);
+ }
+
const resources = await Promise.all(
- monorepo.resources.map(async (config) => {
+ filteredResources.map(async (config) => {
const component = monorepo.component(config.component);
const builder = ResourceFactory.factor(config.type, {
config,
@@ -32,10 +47,14 @@ export default class ResourcesIndex extends FlavoredCommand<
);
if (!flags.json) {
- printTable({
+ const displayData = resources.map((r) => ({
+ ...r,
+ publishable: r.publish ? '✓' : '',
+ }));
+ printTable({
...TABLE_DEFAULTS,
- columns: ['id', 'name', 'type', 'reference'],
- data: resources,
+ columns: ['id', 'name', 'type', 'publishable', 'reference'],
+ data: displayData,
sort: {
id: 'asc',
},
diff --git a/src/cli/commands/resources/publish.ts b/src/cli/commands/resources/publish.ts
new file mode 100644
index 0000000..86941d2
--- /dev/null
+++ b/src/cli/commands/resources/publish.ts
@@ -0,0 +1,45 @@
+import { Args, Flags } from '@oclif/core';
+
+import { FlavoredCommand, getContext } from '@/cli';
+import {
+ PublishResourceMeta,
+ PublishResourcesOperation,
+} from '@/monorepo/operations/resources/PublishResourcesOperation.js';
+
+export default class ResourcesPublishCommand extends FlavoredCommand<
+ typeof ResourcesPublishCommand
+> {
+ static args = {
+ resources: Args.string({
+ description: 'List of resources to publish (defaults to all publishable)',
+ required: false,
+ }),
+ };
+ static description = 'Publish resources to their registries';
+ static examples = [
+ `<%= config.bin %> <%= command.id %> --flavor production`,
+ `<%= config.bin %> <%= command.id %> api:image --flavor production`,
+ ];
+ static flags = {
+ 'dry-run': Flags.boolean({
+ required: false,
+ description: 'Do not publish, just show what would be published',
+ }),
+ };
+ static enableJsonFlag = true;
+ static strict = false;
+
+ async run(): Promise> {
+ const { argv, flags } = await this.parse(ResourcesPublishCommand);
+ const { monorepo } = getContext();
+
+ // If no resources specified, publish all publishable resources
+ const toPublish = argv.length > 0 ? (argv as string[]) : undefined;
+
+ return monorepo.run(new PublishResourcesOperation(), {
+ dryRun: flags['dry-run'],
+ silent: flags.json,
+ resources: toPublish,
+ });
+ }
+}
diff --git a/src/config/schema.json b/src/config/schema.json
index 893f26b..5856a92 100644
--- a/src/config/schema.json
+++ b/src/config/schema.json
@@ -135,6 +135,9 @@
"additionalProperties": {
"type": "string"
}
+ },
+ "publish": {
+ "$ref": "#/definitions/DockerPublishConfig"
}
}
},
@@ -245,6 +248,21 @@
},
"additionalProperties": false
},
+ "DockerPublishConfig": {
+ "type": "object",
+ "description": "Configuration for publishing docker images",
+ "properties": {
+ "registry": {
+ "type": "string",
+ "description": "Registry to push images to (e.g., ghcr.io/myorg, docker.io/mycompany)"
+ },
+ "tag": {
+ "type": "string",
+ "description": "Tag to use when publishing (overrides the build tag)"
+ }
+ },
+ "additionalProperties": false
+ },
"DockerImageConfig": {
"type": "object",
"properties": {
@@ -285,6 +303,10 @@
"dockerfile": {
"type": "string",
"description": "The Dockerfile to use"
+ },
+ "publish": {
+ "$ref": "#/definitions/DockerPublishConfig",
+ "description": "Publishing configuration for this image (overrides defaults.docker.publish)"
}
},
"additionalProperties": false
@@ -308,6 +330,10 @@
"properties": {
"type": { "type": "string" },
"params": {},
+ "publish": {
+ "type": "boolean",
+ "description": "Whether this resource should be published. Defaults to false."
+ },
"dependencies": {
"type": "array",
"items": { "$ref": "#/definitions/QualifiedIdentifier" },
diff --git a/src/docker/credentials.ts b/src/docker/credentials.ts
new file mode 100644
index 0000000..71b8727
--- /dev/null
+++ b/src/docker/credentials.ts
@@ -0,0 +1,209 @@
+import { spawn } from 'node:child_process';
+import { readFile } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+
+interface DockerAuthConfig {
+ password: string;
+ serveraddress?: string;
+ username: string;
+}
+
+interface DockerConfig {
+ auths?: Record;
+ credHelpers?: Record;
+ credsStore?: string;
+}
+
+interface CredentialHelperResponse {
+ Secret: string;
+ ServerURL?: string;
+ Username: string;
+}
+
+/**
+ * Extract registry hostname from an image reference.
+ * e.g., "registry.example.com/foo/bar:tag" -> "registry.example.com"
+ * e.g., "foo/bar:tag" -> "https://index.docker.io/v1/" (Docker Hub)
+ */
+function getRegistryFromImage(imageRef: string): string {
+ // Remove tag if present
+ const withoutTag = imageRef.split(':')[0];
+ const parts = withoutTag.split('/');
+
+ // If first part contains a dot or colon, it's a registry
+ if (parts.length > 1 && (parts[0].includes('.') || parts[0].includes(':'))) {
+ return parts[0];
+ }
+
+ // Default to Docker Hub
+ return 'https://index.docker.io/v1/';
+}
+
+/**
+ * Read Docker config file from ~/.docker/config.json
+ */
+async function readDockerConfig(): Promise {
+ try {
+ const configPath = join(homedir(), '.docker', 'config.json');
+ const content = await readFile(configPath, 'utf8');
+ return JSON.parse(content);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Get credentials from a credential helper binary.
+ * Credential helpers are named docker-credential-.
+ */
+async function getCredentialsFromHelper(
+ helper: string,
+ registry: string,
+): Promise {
+ return new Promise((resolve) => {
+ const helperName = `docker-credential-${helper}`;
+ const proc = spawn(helperName, ['get']);
+
+ let stdout = '';
+ let stderr = '';
+
+ proc.stdout.on('data', (data) => {
+ stdout += data.toString();
+ });
+
+ proc.stderr.on('data', (data) => {
+ stderr += data.toString();
+ });
+
+ proc.on('error', () => {
+ resolve(null);
+ });
+
+ proc.on('close', (code) => {
+ if (code !== 0 || stderr) {
+ resolve(null);
+ return;
+ }
+
+ try {
+ const response: CredentialHelperResponse = JSON.parse(stdout);
+
+ if (response.Username && response.Secret) {
+ resolve({
+ username: response.Username,
+ password: response.Secret,
+ serveraddress: response.ServerURL || registry,
+ });
+ } else {
+ resolve(null);
+ }
+ } catch {
+ resolve(null);
+ }
+ });
+
+ proc.stdin.write(registry);
+ proc.stdin.end();
+ });
+}
+
+/**
+ * Get credentials from the auths section of Docker config (base64 encoded).
+ */
+function getCredentialsFromAuths(
+ auths: Record,
+ registry: string,
+): DockerAuthConfig | null {
+ // Try exact match first
+ let authEntry = auths[registry];
+
+ // Try with https:// prefix
+ if (!authEntry && !registry.startsWith('http')) {
+ authEntry = auths[`https://${registry}`];
+ }
+
+ // Try without https:// prefix
+ if (!authEntry && registry.startsWith('https://')) {
+ authEntry = auths[registry.replace('https://', '')];
+ }
+
+ if (!authEntry?.auth) {
+ return null;
+ }
+
+ try {
+ const decoded = Buffer.from(authEntry.auth, 'base64').toString('utf8');
+ const [username, password] = decoded.split(':');
+
+ if (username && password) {
+ return { username, password, serveraddress: registry };
+ }
+ } catch {
+ // Invalid base64
+ }
+
+ return null;
+}
+
+/**
+ * Get Docker authentication credentials for pushing an image.
+ *
+ * Resolution order:
+ * 1. DOCKER_USERNAME and DOCKER_PASSWORD environment variables
+ * 2. Credential helper from credHelpers (registry-specific)
+ * 3. Default credential store from credsStore
+ * 4. Base64-encoded credentials from auths
+ * 5. Return undefined to let Docker use its defaults
+ *
+ * @param imageRef - The full image reference (e.g., "registry.example.com/foo/bar:tag")
+ * @returns Authentication config or undefined if relying on Docker defaults
+ */
+export async function getDockerAuthConfig(
+ imageRef: string,
+): Promise {
+ // 1. Check environment variables first (explicit config takes priority)
+ if (process.env.DOCKER_USERNAME && process.env.DOCKER_PASSWORD) {
+ return {
+ username: process.env.DOCKER_USERNAME,
+ password: process.env.DOCKER_PASSWORD,
+ };
+ }
+
+ const config = await readDockerConfig();
+ if (!config) {
+ return undefined;
+ }
+
+ const registry = getRegistryFromImage(imageRef);
+
+ // 2. Check for registry-specific credential helper
+ if (config.credHelpers?.[registry]) {
+ const creds = await getCredentialsFromHelper(
+ config.credHelpers[registry],
+ registry,
+ );
+ if (creds) {
+ return creds;
+ }
+ }
+
+ // 3. Check for default credential store
+ if (config.credsStore) {
+ const creds = await getCredentialsFromHelper(config.credsStore, registry);
+ if (creds) {
+ return creds;
+ }
+ }
+
+ // 4. Check for base64-encoded credentials in auths
+ if (config.auths) {
+ const creds = getCredentialsFromAuths(config.auths, registry);
+ if (creds) {
+ return creds;
+ }
+ }
+
+ // 5. No credentials found - return undefined to let Docker handle it
+ return undefined;
+}
diff --git a/src/docker/operations/images/PushImagesOperation.ts b/src/docker/operations/images/PushImagesOperation.ts
index 2a7281c..8145183 100644
--- a/src/docker/operations/images/PushImagesOperation.ts
+++ b/src/docker/operations/images/PushImagesOperation.ts
@@ -3,6 +3,7 @@ import { join } from 'node:path/posix';
import { Transform, Writable } from 'node:stream';
import * as z from 'zod';
+import { getDockerAuthConfig } from '@/docker/credentials.js';
import { ResourceFactory } from '@/monorepo/resources/ResourceFactory.js';
import { AbstractOperation } from '@/operations';
@@ -106,14 +107,11 @@ export class PushImagesOperation extends AbstractOperation<
}
private async pushImage(repo: string, tag: string, out?: Writable) {
- const dockerImage = await this.context.docker.getImage(`${repo}:${tag}`);
+ const imageRef = `${repo}:${tag}`;
+ const dockerImage = await this.context.docker.getImage(imageRef);
- const stream = await dockerImage.push({
- authconfig: {
- username: process.env.DOCKER_USERNAME,
- password: process.env.DOCKER_PASSWORD,
- },
- });
+ const authconfig = await getDockerAuthConfig(imageRef);
+ const stream = await dockerImage.push(authconfig ? { authconfig } : {});
const transform = new Transform({
transform(chunk, encoding, callback) {
diff --git a/src/docker/resources/DockerImageResource.ts b/src/docker/resources/DockerImageResource.ts
index 1040d8d..083c77b 100644
--- a/src/docker/resources/DockerImageResource.ts
+++ b/src/docker/resources/DockerImageResource.ts
@@ -1,9 +1,12 @@
+import { getContext } from '@';
import { fdir as Fdir } from 'fdir';
import { stat, statfs } from 'node:fs/promises';
import { join } from 'node:path';
-import { Writable } from 'node:stream';
+import { join as posixJoin } from 'node:path/posix';
+import { Transform, Writable } from 'node:stream';
import pMap from 'p-map';
+import { DockerPublishConfig } from '@/config/schema.js';
import { ResourceInfo, SentinelFileBasedBuilder } from '@/monorepo';
import { OpInput, OpOutput } from '@/operations/index.js';
import { FilePrerequisite, GitPrerequisitePlugin } from '@/prerequisites';
@@ -12,6 +15,7 @@ import {
ResourceBuildContext,
ResourceFactory,
} from '../../monorepo/resources/ResourceFactory.js';
+import { getDockerAuthConfig } from '../credentials.js';
import { BuildImageOperation } from '../operations/index.js';
/**
@@ -23,6 +27,8 @@ type DockerImageResourceConfig = Partial> & {
image?: string;
/** Image tag. Defaults to defaults.docker.tag or 'latest'. */
tag?: string;
+ /** Publishing configuration (overrides defaults.docker.publish). */
+ publish?: DockerPublishConfig;
};
class DockerImageResourceBuilder extends SentinelFileBasedBuilder<
@@ -118,6 +124,118 @@ class DockerImageResourceBuilder extends SentinelFileBasedBuilder<
return { mtime: lastUpdated.time.getTime() };
}
+ /**
+ * Publish (push) the docker image to a registry.
+ * Uses configuration from defaults.docker.publish and resource-level params.publish.
+ */
+ async publish(
+ _resource: ResourceInfo,
+ out?: Writable,
+ ): Promise {
+ const { docker } = getContext();
+
+ const reference = await this.getReference();
+
+ // Merge defaults with resource-specific config (resource wins)
+ const defaults = this.monorepo.defaults.docker?.publish;
+ const resourceConfig = this.config?.publish;
+ const rawRegistry = resourceConfig?.registry ?? defaults?.registry;
+ const rawTag = resourceConfig?.tag ?? defaults?.tag;
+
+ // Expand any template variables in publish config
+ const expandedRegistry = rawRegistry
+ ? await this.monorepo.expand(rawRegistry)
+ : undefined;
+ const expandedTag = rawTag ? await this.monorepo.expand(rawTag) : undefined;
+
+ // Determine final image name and tag
+ const { imgName, tag } = await this.retagIfNecessary(
+ docker,
+ reference,
+ expandedTag,
+ expandedRegistry,
+ );
+
+ // Push the image
+ await this.pushImage(docker, imgName, tag, out);
+ }
+
+ private async retagIfNecessary(
+ docker: ReturnType['docker'],
+ fullName: string,
+ retag?: string,
+ registry?: string,
+ ) {
+ let [imgName, tag] = fullName.split(':');
+
+ // Retag if necessary
+ if (retag || registry) {
+ const dockerImage = docker.getImage(fullName);
+
+ tag = retag || tag;
+ imgName = registry ? posixJoin(registry, imgName) : imgName;
+
+ await dockerImage.tag({
+ tag,
+ repo: imgName,
+ });
+ }
+
+ return { imgName, tag };
+ }
+
+ private async pushImage(
+ docker: ReturnType['docker'],
+ repo: string,
+ tag: string,
+ out?: Writable,
+ ) {
+ const imageRef = `${repo}:${tag}`;
+ const dockerImage = docker.getImage(imageRef);
+
+ const authconfig = await getDockerAuthConfig(imageRef);
+ const stream = await dockerImage.push(authconfig ? { authconfig } : {});
+
+ const transform = new Transform({
+ transform(chunk, encoding, callback) {
+ const lines = chunk.toString().split('\n');
+ lines.forEach((line: string) => {
+ if (!line.trim()) {
+ return;
+ }
+
+ try {
+ const { status } = JSON.parse(line.trim());
+ out?.write(status + '\n');
+ } catch (error) {
+ out?.write(error + '\n');
+ }
+ });
+
+ callback();
+ },
+ });
+
+ if (out) {
+ stream.pipe(transform).pipe(out);
+ }
+
+ await new Promise((resolve, reject) => {
+ docker.modem.followProgress(stream, (err, data) => {
+ if (err) {
+ return reject(err);
+ }
+
+ const hasError = data.find((d) => Boolean(d.error));
+ if (hasError) {
+ return reject(new Error(hasError.error));
+ }
+
+ resolve(null);
+ });
+ });
+ }
+
private async lastUpdatedInfo(sources: Array) {
const stats = await pMap(
sources,
diff --git a/src/kubernetes/operations/GetDeploymentPodsOperation.ts b/src/kubernetes/operations/GetDeploymentPodsOperation.ts
index 25ae0af..964302f 100644
--- a/src/kubernetes/operations/GetDeploymentPodsOperation.ts
+++ b/src/kubernetes/operations/GetDeploymentPodsOperation.ts
@@ -21,7 +21,8 @@ export class GetDeploymentPodsOperation extends AbstractOperation<
const { kubernetes, monorepo } = getContext();
const selectorLabel =
- monorepo.config.defaults?.kubernetes?.selectorLabel ?? 'app.kubernetes.io/component';
+ monorepo.config.defaults?.kubernetes?.selectorLabel ??
+ 'app.kubernetes.io/component';
const res = await kubernetes.core.listNamespacedPod({
namespace: input.namespace,
diff --git a/src/monorepo/operations/resources/PublishResourcesOperation.ts b/src/monorepo/operations/resources/PublishResourcesOperation.ts
new file mode 100644
index 0000000..a226055
--- /dev/null
+++ b/src/monorepo/operations/resources/PublishResourcesOperation.ts
@@ -0,0 +1,164 @@
+import { ListrTask, PRESET_TIMER } from 'listr2';
+import * as z from 'zod';
+
+import { CliError } from '@/errors.js';
+import {
+ EMBCollection,
+ findRunOrder,
+ ResourceFactory,
+ ResourceInfo,
+} from '@/monorepo';
+import { AbstractOperation } from '@/operations';
+
+const schema = z.object({
+ resources: z
+ .array(z.string())
+ .describe('The list of resources to publish')
+ .optional(),
+ dryRun: z
+ .boolean()
+ .optional()
+ .describe('Do not publish, just show what would be published'),
+ silent: z
+ .boolean()
+ .optional()
+ .describe('Do not produce any output on the terminal'),
+});
+
+export type PublishResourceMeta = {
+ resource: ResourceInfo;
+ reference: string;
+ skipped?: boolean;
+ skipReason?: string;
+};
+
+export class PublishResourcesOperation extends AbstractOperation<
+ typeof schema,
+ Record
+> {
+ constructor() {
+ super(schema);
+ }
+
+ protected async _run(
+ input: z.input,
+ ): Promise> {
+ const { monorepo } = this.context;
+ const manager = monorepo.taskManager();
+
+ // Filter to only publishable resources (publish: true)
+ const publishableResources = monorepo.resources.filter(
+ (r) => r.publish === true,
+ );
+
+ // Return early if no publishable resources
+ if (publishableResources.length === 0) {
+ return {};
+ }
+
+ // If specific resources requested, filter to those
+ let targetResources: ResourceInfo[];
+ if (input.resources && input.resources.length > 0) {
+ const collection = new EMBCollection(publishableResources, {
+ idField: 'id',
+ depField: 'dependencies',
+ });
+ targetResources = findRunOrder(input.resources, collection);
+ } else {
+ // All publishable resources
+ const collection = new EMBCollection(publishableResources, {
+ idField: 'id',
+ depField: 'dependencies',
+ });
+ targetResources = findRunOrder(
+ publishableResources.map((r) => r.id),
+ collection,
+ );
+ }
+
+ // Verify each resource's builder supports publish
+ for (const resource of targetResources) {
+ const component = monorepo.component(resource.component);
+ const builder = ResourceFactory.factor(resource.type, {
+ config: resource,
+ monorepo,
+ component,
+ });
+
+ if (typeof builder.publish !== 'function') {
+ throw new CliError(
+ 'PUBLISH_NOT_SUPPORTED',
+ `Resource "${resource.id}" has publish: true but resource type "${resource.type}" does not support publishing.`,
+ [
+ `Remove "publish: true" from the resource configuration`,
+ `Use a different resource type that supports publishing (e.g., docker/image)`,
+ ],
+ );
+ }
+ }
+
+ const tasks: Array = targetResources.map((resource) => {
+ return {
+ title: `Publish ${resource.id}`,
+ async task(ctx, task) {
+ const component = monorepo.component(resource.component);
+ const builder = ResourceFactory.factor(resource.type, {
+ config: resource,
+ monorepo,
+ component,
+ });
+
+ const reference = await builder.getReference();
+
+ ctx[resource.id] = {
+ resource,
+ reference,
+ };
+
+ if (input.dryRun) {
+ ctx[resource.id].skipped = true;
+ ctx[resource.id].skipReason = 'dry run';
+ task.title = `[dry run] ${resource.id} → ${reference}`;
+ return task.skip();
+ }
+
+ task.title = `Publishing ${resource.id} → ${reference}`;
+ await builder.publish!(resource, task.stdout());
+ },
+ };
+ });
+
+ if (tasks.length === 0) {
+ return {};
+ }
+
+ return manager.run(
+ [
+ {
+ title: 'Publish resources',
+ async task(_ctx, task) {
+ return task.newListr(tasks, {
+ rendererOptions: {
+ collapseSubtasks: false,
+ collapseSkips: true,
+ },
+ });
+ },
+ },
+ ],
+ {
+ silentRendererCondition() {
+ return Boolean(input.silent);
+ },
+ rendererOptions: {
+ collapseSkips: true,
+ collapseSubtasks: true,
+ timer: {
+ ...PRESET_TIMER,
+ },
+ },
+ ctx: {} as Record,
+ },
+ );
+ }
+}
diff --git a/tests/unit/monorepo/operations/resources/PublishResourcesOperation.spec.ts b/tests/unit/monorepo/operations/resources/PublishResourcesOperation.spec.ts
new file mode 100644
index 0000000..94a26ba
--- /dev/null
+++ b/tests/unit/monorepo/operations/resources/PublishResourcesOperation.spec.ts
@@ -0,0 +1,229 @@
+import { mkdir } from 'node:fs/promises';
+import { join } from 'node:path';
+import { createTestSetup, TestSetup } from 'tests/setup/set.context.js';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+import { CliError } from '../../../../../src/errors.js';
+import { PublishResourcesOperation } from '../../../../../src/monorepo/operations/resources/PublishResourcesOperation.js';
+import { ResourceFactory } from '../../../../../src/monorepo/resources/ResourceFactory.js';
+
+describe('Monorepo / Operations / Resources / PublishResourcesOperation', () => {
+ let setup: TestSetup;
+
+ beforeEach(async () => {
+ setup = await createTestSetup({
+ tempDirPrefix: 'embPublishResourcesTest',
+ embfile: {
+ project: { name: 'test-publish' },
+ plugins: [],
+ components: {
+ api: {
+ resources: {
+ image: {
+ type: 'docker/image',
+ publish: true,
+ },
+ config: {
+ type: 'file',
+ publish: false,
+ params: {
+ path: 'config.txt',
+ },
+ },
+ },
+ },
+ web: {
+ resources: {
+ image: {
+ type: 'docker/image',
+ publish: true,
+ },
+ },
+ },
+ internal: {
+ resources: {
+ image: {
+ type: 'docker/image',
+ // No publish flag - should not be published
+ },
+ },
+ },
+ },
+ },
+ });
+ await mkdir(join(setup.tempDir, 'api'), { recursive: true });
+ await mkdir(join(setup.tempDir, 'web'), { recursive: true });
+ await mkdir(join(setup.tempDir, 'internal'), { recursive: true });
+ });
+
+ afterEach(async () => {
+ await setup.cleanup();
+ vi.restoreAllMocks();
+ });
+
+ describe('#run()', () => {
+ test('it only selects resources with publish: true', async () => {
+ // Mock the publish method to track calls
+ const publishCalls: string[] = [];
+ const originalFactor = ResourceFactory.factor.bind(ResourceFactory);
+
+ vi.spyOn(ResourceFactory, 'factor').mockImplementation(
+ (type, context) => {
+ const builder = originalFactor(type, context);
+ if (type === 'docker/image') {
+ builder.publish = vi.fn(async () => {
+ publishCalls.push(context.config.id);
+ });
+ }
+
+ return builder;
+ },
+ );
+
+ const result = await setup.monorepo.run(new PublishResourcesOperation(), {
+ silent: true,
+ });
+
+ // Only api:image and web:image should be published (publish: true)
+ // internal:image should not (no publish flag)
+ expect(publishCalls).toContain('api:image');
+ expect(publishCalls).toContain('web:image');
+ expect(publishCalls).not.toContain('internal:image');
+ expect(Object.keys(result)).toHaveLength(2);
+ });
+
+ test('it supports dry run mode without publishing', async () => {
+ const publishCalls: string[] = [];
+ const originalFactor = ResourceFactory.factor.bind(ResourceFactory);
+
+ vi.spyOn(ResourceFactory, 'factor').mockImplementation(
+ (type, context) => {
+ const builder = originalFactor(type, context);
+ if (type === 'docker/image') {
+ builder.publish = vi.fn(async () => {
+ publishCalls.push(context.config.id);
+ });
+ }
+
+ return builder;
+ },
+ );
+
+ const result = await setup.monorepo.run(new PublishResourcesOperation(), {
+ dryRun: true,
+ silent: true,
+ });
+
+ // No actual publish calls in dry run
+ expect(publishCalls).toHaveLength(0);
+
+ // But result should contain the resources that would be published
+ expect(result['api:image']).toBeDefined();
+ expect(result['api:image'].skipped).toBe(true);
+ expect(result['api:image'].skipReason).toBe('dry run');
+ });
+
+ test('it publishes specific resources when specified', async () => {
+ const publishCalls: string[] = [];
+ const originalFactor = ResourceFactory.factor.bind(ResourceFactory);
+
+ vi.spyOn(ResourceFactory, 'factor').mockImplementation(
+ (type, context) => {
+ const builder = originalFactor(type, context);
+ if (type === 'docker/image') {
+ builder.publish = vi.fn(async () => {
+ publishCalls.push(context.config.id);
+ });
+ }
+
+ return builder;
+ },
+ );
+
+ const result = await setup.monorepo.run(new PublishResourcesOperation(), {
+ resources: ['api:image'],
+ silent: true,
+ });
+
+ // Only api:image should be published
+ expect(publishCalls).toEqual(['api:image']);
+ expect(Object.keys(result)).toHaveLength(1);
+ });
+
+ test('it throws error when resource type does not support publishing', async () => {
+ const badSetup = await createTestSetup({
+ tempDirPrefix: 'embPublishBadTest',
+ embfile: {
+ project: { name: 'test-bad-publish' },
+ plugins: [],
+ components: {
+ api: {
+ resources: {
+ config: {
+ type: 'file',
+ publish: true, // file type doesn't support publish
+ params: {
+ path: 'config.txt',
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+
+ await mkdir(join(badSetup.tempDir, 'api'), { recursive: true });
+
+ try {
+ await expect(
+ badSetup.monorepo.run(new PublishResourcesOperation(), {
+ silent: true,
+ }),
+ ).rejects.toThrow(CliError);
+
+ await expect(
+ badSetup.monorepo.run(new PublishResourcesOperation(), {
+ silent: true,
+ }),
+ ).rejects.toThrow(/does not support publishing/);
+ } finally {
+ await badSetup.cleanup();
+ }
+ });
+
+ test('it returns empty result when no publishable resources exist', async () => {
+ const emptySetup = await createTestSetup({
+ tempDirPrefix: 'embPublishEmptyTest',
+ embfile: {
+ project: { name: 'test-empty-publish' },
+ plugins: [],
+ components: {
+ api: {
+ resources: {
+ image: {
+ type: 'docker/image',
+ // No publish: true
+ },
+ },
+ },
+ },
+ },
+ });
+
+ await mkdir(join(emptySetup.tempDir, 'api'), { recursive: true });
+
+ try {
+ const result = await emptySetup.monorepo.run(
+ new PublishResourcesOperation(),
+ {
+ silent: true,
+ },
+ );
+
+ expect(Object.keys(result)).toHaveLength(0);
+ } finally {
+ await emptySetup.cleanup();
+ }
+ });
+ });
+});
diff --git a/website/src/content/docs/reference/cli.md b/website/src/content/docs/reference/cli.md
index 3626913..d57acda 100644
--- a/website/src/content/docs/reference/cli.md
+++ b/website/src/content/docs/reference/cli.md
@@ -119,6 +119,7 @@ emb resources build [RESOURCE...] [OPTIONS]
**Options:**
- `-f, --force` - Force rebuild, bypass cache
- `--dry-run` - Show what would be built without building
+- `--publishable` - Only build resources marked as publishable (and their dependencies)
- `--flavor ` - Use a specific flavor
**Examples:**
@@ -127,6 +128,32 @@ emb resources build # Build all
emb resources build api:image # Build specific resource
emb resources build -f # Force rebuild all
emb resources build --flavor production # Build for production
+emb resources build --publishable # Build only publishable resources
+```
+
+### emb resources publish
+
+Publish resources to their registries (e.g., push Docker images).
+
+```shell
+emb resources publish [RESOURCE...] [OPTIONS]
+```
+
+**Arguments:**
+- `RESOURCE...` - Optional resources to publish (defaults to all publishable)
+
+**Options:**
+- `--dry-run` - Show what would be published without publishing
+- `--flavor ` - Use a specific flavor
+
+Only resources with `publish: true` in their configuration are published. The registry and tag can be configured via `defaults.docker.publish` or per-resource `params.publish`.
+
+**Examples:**
+```shell
+emb resources publish # Publish all publishable resources
+emb resources publish api:image # Publish specific resource
+emb resources publish --dry-run # Preview without pushing
+emb resources publish --flavor production # Publish with production config
```
## List Commands
@@ -154,6 +181,16 @@ List all resources.
emb resources [OPTIONS]
```
+**Options:**
+- `--publishable` - Only show resources marked as publishable
+
+**Example output:**
+```
+ ID NAME TYPE PUBLISHABLE REFERENCE
+ api:image image docker/image ✓ myapp/api:latest
+ web:image image docker/image ✓ myapp/web:latest
+```
+
### emb tasks
List available tasks.
@@ -274,6 +311,7 @@ emb components shell # Get shell in service (alias: emb shell)
```shell
emb resources # List resources
emb resources build # Build resources
+emb resources publish # Publish resources to registries
```
### tasks
diff --git a/website/src/content/docs/reference/configuration.md b/website/src/content/docs/reference/configuration.md
index 612a162..9d2fba8 100644
--- a/website/src/content/docs/reference/configuration.md
+++ b/website/src/content/docs/reference/configuration.md
@@ -80,6 +80,9 @@ defaults:
NODE_ENV: development
labels: # Default labels
maintainer: team@example.com
+ publish: # Default publishing settings
+ registry: ghcr.io/myorg # Registry to push images to
+ tag: ${env:VERSION} # Tag override for publishing
kubernetes:
namespace: staging # Default namespace for K8s operations
selectorLabel: app.kubernetes.io/component # Label for pod selection
@@ -161,6 +164,7 @@ Optional. Resources this component provides.
resources:
image:
type: docker/image
+ publish: true # Mark as publishable (opt-in)
dependencies:
- base:image
params:
@@ -173,6 +177,15 @@ resources:
dockerfile: Dockerfile
```
+**Common resource properties:**
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `type` | string | Resource type (required): `docker/image`, `file` |
+| `publish` | boolean | Mark resource as publishable for `emb resources publish` |
+| `dependencies` | array | List of resource IDs this depends on |
+| `params` | object | Type-specific parameters |
+
**Resource types:**
#### docker/image
@@ -189,6 +202,8 @@ Builds a Docker image.
| `labels` | object | Image labels |
| `context` | string | Build context path |
| `dockerfile` | string | Dockerfile path |
+| `publish.registry` | string | Registry to push to (overrides `defaults.docker.publish.registry`) |
+| `publish.tag` | string | Tag for publishing (overrides `defaults.docker.publish.tag`) |
#### file