Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions .claude/tasks/ideation/publishable-resources.md
Original file line number Diff line number Diff line change
@@ -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<Input>, out?: Writable): Promise<void>;
```

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<DockerImageResourceConfig>, out?: Writable): Promise<void> {
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<typeof schema, void> {
protected async _run(input): Promise<void> {
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
1 change: 1 addition & 0 deletions examples/production-ready/api/Embfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ description: Production-ready API with multi-stage builds
resources:
image:
type: docker/image
publish: true
params:
target: development

Expand Down
1 change: 1 addition & 0 deletions examples/production-ready/web/Embfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ description: Production-ready web frontend
resources:
image:
type: docker/image
publish: true
params:
target: development

Expand Down
7 changes: 6 additions & 1 deletion src/cli/commands/images/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { FlavoredCommand } from '@/cli';
import { PushImagesOperation } from '@/docker/operations/images/PushImagesOperation.js';

export default class ImagesPush extends FlavoredCommand<typeof ImagesPush> {
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 %>',
Expand Down
20 changes: 16 additions & 4 deletions src/cli/commands/resources/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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;
Expand All @@ -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'],
Expand Down
31 changes: 25 additions & 6 deletions src/cli/commands/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Flags } from '@oclif/core';
import { printTable } from '@oclif/table';

import { FlavoredCommand, getContext, TABLE_DEFAULTS } from '@/cli';
Expand All @@ -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<Array<ResourceConfig>> {
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,
Expand All @@ -32,10 +47,14 @@ export default class ResourcesIndex extends FlavoredCommand<
);

if (!flags.json) {
printTable<ResourceConfig>({
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',
},
Expand Down
Loading