Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7a54c0d
feat(soci): scaffold soci plugin and register entry point
ianpittwood May 28, 2026
a7a5d37
feat(soci): add SociOptions tool configuration
ianpittwood May 28, 2026
43a6690
fix(soci): correct SociOptions.update for default_factory fields
ianpittwood May 28, 2026
5f8033a
feat(soci): add SociCommand base class and find_soci_bin
ianpittwood May 28, 2026
e30d934
feat(soci): add SociConvert command wrapper
ianpittwood May 28, 2026
385e051
feat(soci): add SociPush command wrapper
ianpittwood May 28, 2026
660d433
test(soci): add SociPush coverage for containerd_address and existing…
ianpittwood May 28, 2026
ee00313
feat(soci): add ContainerdImagePull helper and not-found regex
ianpittwood May 28, 2026
c9f697b
test(soci): cover ContainerdImagePull dry-run and tighten find_ctr_bi…
ianpittwood May 28, 2026
7ef702b
feat(soci): add SociConvertWorkflow single-namespace happy path
ianpittwood May 28, 2026
c59e7ba
feat(soci): probe containerd namespaces in convert workflow
ianpittwood May 28, 2026
0f4e77e
feat(soci): add standalone-mode branch to convert workflow
ianpittwood May 28, 2026
170e4d3
feat(soci): implement SociPlugin.execute with per-target gating
ianpittwood May 28, 2026
3caa0e5
fix(soci): defer binary lookup until at least one target is eligible
ianpittwood May 28, 2026
6ab475b
feat(soci): implement SociPlugin.results display and exit
ianpittwood May 28, 2026
f345baf
feat(soci): add bakery soci convert CLI command
ianpittwood May 28, 2026
d0c171b
feat(oras): extract OrasIndexCreateWorkflow primitive
ianpittwood May 28, 2026
a6d086c
feat(oras): extract OrasIndexCopyWorkflow primitive
ianpittwood May 28, 2026
50602ef
feat(oras): extract OrasIndexCleanupWorkflow primitive
ianpittwood May 28, 2026
652ba68
refactor(oras): compose OrasMergeWorkflow from new primitives
ianpittwood May 28, 2026
e94b170
feat(ci): add bakery ci publish orchestrator
ianpittwood May 28, 2026
25aec31
refactor(ci): make bakery ci merge alias bakery ci publish
ianpittwood May 28, 2026
484d446
feat(ci): add setup-soci composite action
ianpittwood May 28, 2026
5ef9751
feat(ci): wire SOCI into bakery-build-native workflow
ianpittwood May 28, 2026
21f38ab
docs(ci): note SOCI deferral on bakery-build (QEMU) workflow
ianpittwood May 28, 2026
e779f7d
test(soci): cover explicit-default-value cases in SociOptions.update
ianpittwood May 28, 2026
306acd0
test: point internal action refs at feature/soci-images (DROP BEFORE …
ianpittwood May 29, 2026
87e9953
test: force wide unstyled terminal in help-output CLI tests
ianpittwood May 29, 2026
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
26 changes: 19 additions & 7 deletions .github/workflows/bakery-build-native.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ on:
default: true
required: false
type: boolean
enable-soci:
description: "Convert merged images to SOCI-enabled images [default: false]"
default: false
required: false
type: boolean
merge-builder:
description: "The type of runner to use for merging [default: ubuntu-latest-4x]"
default: "ubuntu-latest-4x"
Expand Down Expand Up @@ -110,7 +115,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install
uses: "posit-dev/images-shared/setup-bakery@main"
uses: "posit-dev/images-shared/setup-bakery@feature/soci-images"
with:
version: ${{ inputs.version }}

Expand Down Expand Up @@ -147,12 +152,12 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup bakery
uses: "posit-dev/images-shared/setup-bakery@main"
uses: "posit-dev/images-shared/setup-bakery@feature/soci-images"
with:
version: ${{ inputs.version }}

- name: Setup goss
uses: "posit-dev/images-shared/setup-goss@main"
uses: "posit-dev/images-shared/setup-goss@feature/soci-images"

- name: Set up Docker
uses: docker/setup-docker-action@b2189fbf2a6592b51fee7cdd93ee2bfaeba733db # v5.1.0
Expand Down Expand Up @@ -287,7 +292,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup bakery
uses: "posit-dev/images-shared/setup-bakery@main"
uses: "posit-dev/images-shared/setup-bakery@feature/soci-images"
with:
version: ${{ inputs.version }}

Expand Down Expand Up @@ -344,6 +349,10 @@ jobs:
- name: Setup ORAS CLI
uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0

- name: Setup SOCI
if: ${{ inputs.enable-soci }}
uses: "posit-dev/images-shared/setup-soci@feature/soci-images"

- name: Download Metadata
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
Expand All @@ -354,18 +363,21 @@ jobs:
run: |
ls -ltra .

- name: Merge/Push
- name: Publish
env:
GIT_SHA: ${{ github.sha }}
CONTEXT: ${{ inputs.context }}
REGISTRY: ghcr.io/${{ github.repository_owner }}
PUSH: ${{ inputs.push }}
ENABLE_SOCI: ${{ inputs.enable-soci }}
run: |
if [ "$PUSH" = "true" ]; then PUSH_FLAG=""; else PUSH_FLAG="--dry-run"; fi
bakery ci merge \
if [ "$ENABLE_SOCI" = "true" ]; then SOCI_FLAG="--enable-soci"; else SOCI_FLAG="--no-enable-soci"; fi
bakery ci publish \
--context "$CONTEXT" \
--temp-registry "$REGISTRY" \
$PUSH_FLAG \
$SOCI_FLAG \
./*-metadata.json

readme:
Expand All @@ -382,7 +394,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup bakery
uses: "posit-dev/images-shared/setup-bakery@main"
uses: "posit-dev/images-shared/setup-bakery@feature/soci-images"
with:
version: ${{ inputs.version }}

Expand Down
14 changes: 10 additions & 4 deletions .github/workflows/bakery-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
# This workflow will be called to build individual images so should be kept
# as shallow as possible.

# Note: SOCI conversion is intentionally not wired into this workflow.
# Unlike bakery-build-native.yml, this QEMU-based workflow has no
# separate merge phase to inject SOCI between. Product repos that need
# SOCI should use bakery-build-native.yml instead. See
# docs/superpowers/specs/2026-05-18-soci-indexing-design.md.

name: Bakery

on:
Expand Down Expand Up @@ -101,7 +107,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Install
uses: "posit-dev/images-shared/setup-bakery@main"
uses: "posit-dev/images-shared/setup-bakery@feature/soci-images"
with:
version: ${{ inputs.version }}

Expand Down Expand Up @@ -138,12 +144,12 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup bakery
uses: "posit-dev/images-shared/setup-bakery@main"
uses: "posit-dev/images-shared/setup-bakery@feature/soci-images"
with:
version: ${{ inputs.version }}

- name: Setup goss
uses: "posit-dev/images-shared/setup-goss@main"
uses: "posit-dev/images-shared/setup-goss@feature/soci-images"

- name: Setup QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
Expand Down Expand Up @@ -269,7 +275,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Setup bakery
uses: "posit-dev/images-shared/setup-bakery@main"
uses: "posit-dev/images-shared/setup-bakery@feature/soci-images"
with:
version: ${{ inputs.version }}

Expand Down
167 changes: 124 additions & 43 deletions posit-bakery/posit_bakery/cli/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,22 +177,61 @@ def merge(
),
] = None,
):
"""Merges multiple metadata files with single-platform images into a single multi-platform image by UID.
This command is intended for use in CI workflows that utilize native builders for multiplatform builds.
Easier multiplatform builds can be achieved by using emulation (Docker and QEMU), but builds in emulation typically
suffer severe performance disadvantages.
This command should be ran after multiple `bakery build --strategy build --platform <platform>
--metadata-file <path> --temp-registry <registry>` commands have been executed for different platforms. The
resulting metadata files can be fed into this command to merge and push combined multi-platform images. Matches are
made by the top-level Image UID keys in the metadata files. Single entries with no other matches will be tagged and
pushed as is. If an entry has no matching UID in the project, it will be skipped with a delayed error.
Metadata files are expected to be JSON with the following structure:
```json
{
"<Image UID>": {metadata...}
}
```
"""Alias for `bakery ci publish --no-enable-soci`.

Preserved for back-compat. New callers should prefer `bakery ci publish`.
"""
publish(
metadata_file=metadata_file,
context=context,
temp_registry=temp_registry,
enable_soci=False,
dry_run=dry_run,
dev_stream=dev_stream,
)


@app.command()
@with_verbosity_flags
def publish(
metadata_file: Annotated[list[Path], typer.Argument(help="Path to input build metadata JSON file(s).")],
context: Annotated[
Path, typer.Option(help="The root path to use. Defaults to the current working directory.")
] = auto_path(),
temp_registry: Annotated[
Optional[str],
typer.Option(
help="Temporary registry to use for split/merge builds.", rich_help_panel="Build Configuration & Outputs"
),
] = None,
enable_soci: Annotated[
bool,
typer.Option("--enable-soci/--no-enable-soci", help="Run SOCI conversion between merge-create and merge-copy."),
] = False,
dry_run: Annotated[bool, typer.Option(help="If set, no images will be pushed.")] = False,
dev_stream: Annotated[
Optional[ReleaseStreamEnum],
typer.Option(
help="Filter development versions to a specific release stream.", rich_help_panel=RichHelpPanelEnum.FILTERS
),
] = None,
) -> None:
"""Publish multi-platform images by composing oras index-create →
optional soci-convert → oras index-copy → cleanup.

Replaces `bakery ci merge`; the latter is preserved as a thin alias
that calls this command with `--no-enable-soci`.
"""
# Imports kept local to mirror existing patterns and to avoid bloating
# module load time when this command isn't invoked.
from posit_bakery.plugins.builtin.oras.oras import (
OrasIndexCleanupWorkflow,
OrasIndexCopyWorkflow,
OrasIndexCreateWorkflow,
find_oras_bin,
)
from posit_bakery.plugins.registry import get_plugin

settings = BakerySettings(
dev_versions=DevVersionInclusionEnum.INCLUDE,
dev_stream=dev_stream,
Expand All @@ -202,51 +241,93 @@ def merge(
)
config: BakeryConfig = BakeryConfig.from_context(context, settings)

# Resolve glob patterns in metadata_file arguments
resolved_files: list[Path] = []
for file in metadata_file:
if "*" in str(file) or "?" in str(file) or "[" in str(file):
resolved_files.extend(sorted(Path(x).absolute() for x in glob.glob(str(file))))
for f in metadata_file:
s = str(f)
if "*" in s or "?" in s or "[" in s:
resolved_files.extend(sorted(Path(x).absolute() for x in glob.glob(s)))
else:
resolved_files.append(file.absolute())
resolved_files.append(f.absolute())
metadata_file = resolved_files

log.info(f"Reading targets from {', '.join(f.name for f in metadata_file)}")

files_ok = True
loaded_targets: list[str] = []
for file in metadata_file:
for f in metadata_file:
try:
loaded_targets.extend(config.load_build_metadata_from_file(file))
loaded_targets.extend(config.load_build_metadata_from_file(f))
except Exception as e:
log.error(f"Failed to load metadata from file '{file}'")
log.error(str(e))
log.error(f"Failed to load metadata from file '{f}': {e}")
files_ok = False
loaded_targets = list(set(loaded_targets)) # Deduplicate targets in case of overlap across files

if not files_ok:
log.error("One or more metadata files are invalid, aborting merge.")
raise typer.Exit(code=1)

loaded_targets = list(set(loaded_targets)) # Deduplicate targets in case of overlap across files
log.info(f"Found {len(loaded_targets)} targets")
log.debug(", ".join(loaded_targets))

# Imported locally for patching in CLI tests
from posit_bakery.plugins.registry import get_plugin

oras = get_plugin("oras")
results = oras.execute(config.base_path, config.targets, dry_run=dry_run)

# CI-specific: verify final manifests with imagetools inspect
if not dry_run:
for result in results:
if result.exit_code == 0 and result.artifacts:
workflow_result = result.artifacts.get("workflow_result")
if workflow_result and workflow_result.destinations:
manifest = python_on_whales.docker.buildx.imagetools.inspect(workflow_result.destinations[0])
stdout_console.print_json(manifest.model_dump_json(indent=2, exclude_unset=True, exclude_none=True))

oras.results(results)
oras_bin = find_oras_bin(config.base_path)
targets = sorted(config.targets, key=lambda t: t.push_sort_key)

# Phase 1: index create. Failures abort.
temp_refs: dict[str, str] = {}
for t in targets:
if not t.get_merge_sources():
log.debug(f"Skipping target '{t}' (no merge sources).")
continue
if not t.settings.temp_registry:
log.error(f"Cannot publish '{t}': temp_registry not configured.")
raise typer.Exit(code=1)
res = OrasIndexCreateWorkflow(
oras_bin=oras_bin,
image_target=t,
annotations=t.labels,
).run(dry_run=dry_run)
if not res.success:
log.error(f"index-create failed for '{t}': {res.error}")
raise typer.Exit(code=1)
temp_refs[t.uid] = res.temp_ref # type: ignore[assignment]

# Phase 2: SOCI convert (conditional).
cleanup_refs: list[str] = list(temp_refs.values())
if enable_soci:
soci = get_plugin("soci")
soci_results = soci.execute(
config.base_path,
targets,
source_refs=temp_refs,
dry_run=dry_run,
)
for r in soci_results:
artifacts = r.artifacts or {}
if artifacts.get("skipped"):
continue
wf = artifacts.get("workflow_result")
if r.exit_code != 0:
soci.results(soci_results) # raises typer.Exit(1)
if wf and getattr(wf, "destination_ref", None):
temp_refs[r.target.uid] = wf.destination_ref
cleanup_refs.append(wf.destination_ref)

# Phase 3: index copy.
copy_failed = False
for t in targets:
if t.uid not in temp_refs:
continue
copy = OrasIndexCopyWorkflow(
oras_bin=oras_bin,
image_target=t,
).run(source=temp_refs[t.uid], dry_run=dry_run)
if not copy.success:
log.error(f"index-copy failed for '{t}': {copy.error}")
copy_failed = True

# Phase 4: cleanup (non-fatal).
OrasIndexCleanupWorkflow(oras_bin=oras_bin).run(refs=cleanup_refs, dry_run=dry_run)

if copy_failed:
raise typer.Exit(code=1)


@app.command()
Expand Down
Loading
Loading