Skip to content
Open
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
52 changes: 48 additions & 4 deletions .github/workflows/bakery-build-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

- name: Install
uses: "posit-dev/images-shared/setup-bakery@main"
Expand All @@ -103,8 +105,13 @@ jobs:
DEV_VERSIONS: ${{ inputs.dev-versions }}
MATRIX_VERSIONS: ${{ inputs.matrix-versions }}
BAKERY_CONTEXT: ${{ inputs.context }}
BASE_REF: ${{ github.event.pull_request.base.sha }}
run: |
FULL_MATRIX=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" --context "$BAKERY_CONTEXT" | jq --compact-output .)
BASE_FLAG=()
if [ -n "$BASE_REF" ]; then
BASE_FLAG=(--base-ref "$BASE_REF")
fi
FULL_MATRIX=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" "${BASE_FLAG[@]}" --context "$BAKERY_CONTEXT" | jq --compact-output .)
if [ "$IS_FORK" = "true" ]; then
# Skip arm64 for fork PRs — paid runners may not be available
FULL_MATRIX=$(echo "$FULL_MATRIX" | jq --compact-output '[.[] | select(.platform != "linux/arm64")]')
Expand All @@ -117,15 +124,23 @@ jobs:
DEV_VERSIONS: ${{ inputs.dev-versions }}
MATRIX_VERSIONS: ${{ inputs.matrix-versions }}
BAKERY_CONTEXT: ${{ inputs.context }}
BASE_REF: ${{ github.event.pull_request.base.sha }}
run: |
result=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" --exclude platform --context "$BAKERY_CONTEXT")
BASE_FLAG=()
if [ -n "$BASE_REF" ]; then
BASE_FLAG=(--base-ref "$BASE_REF")
fi
result=$(bakery ci matrix --quiet --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS" "${BASE_FLAG[@]}" --exclude platform --context "$BAKERY_CONTEXT")
echo "versions_matrix=$(echo "$result" | jq --compact-output .)" >> "$GITHUB_OUTPUT"

build-test:
name: "Build/Test ${{ matrix.img.image }}:${{ matrix.img.version }} (${{ matrix.img.platform }})"
needs:
- detect
- matrix
# GitHub Actions fails (not skips) a matrix job when the matrix evaluates to [].
# Guard here so an empty change-aware matrix produces 'skipped' instead of 'failure'.
if: ${{ needs.matrix.outputs.platform-matrix != '[]' }}
timeout-minutes: 120
permissions:
contents: read
Expand Down Expand Up @@ -188,13 +203,18 @@ jobs:
IMAGE_NAME: ${{ matrix.img.image }}
IMAGE_VERSION: ${{ matrix.img.version }}
IMAGE_PLATFORM: ${{ matrix.img.platform }}
DEV_VERSIONS: ${{ inputs.dev-versions }}
IMG_DEV: ${{ matrix.img.dev }}
MATRIX_VERSIONS: ${{ inputs.matrix-versions }}
BAKERY_CONTEXT: ${{ inputs.context }}
REGISTRY_OWNER: ${{ github.repository_owner }}
CACHE: ${{ inputs.cache }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "$IMG_DEV" = "true" ]; then
DEV_VERSIONS=only
else
DEV_VERSIONS=exclude
fi
CACHE_FLAGS=()
if [ "$IS_FORK" != "true" ] && [ "$CACHE" = "true" ]; then
CACHE_FLAGS=(--cache-registry "ghcr.io/${REGISTRY_OWNER}")
Expand All @@ -215,11 +235,16 @@ jobs:
IMAGE_NAME: ${{ matrix.img.image }}
IMAGE_VERSION: ${{ matrix.img.version }}
IMAGE_PLATFORM: ${{ matrix.img.platform }}
DEV_VERSIONS: ${{ inputs.dev-versions }}
IMG_DEV: ${{ matrix.img.dev }}
MATRIX_VERSIONS: ${{ inputs.matrix-versions }}
BAKERY_CONTEXT: ${{ inputs.context }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "$IMG_DEV" = "true" ]; then
DEV_VERSIONS=only
else
DEV_VERSIONS=exclude
fi
GOSS_PATH=${GITHUB_WORKSPACE}/tools/goss \
DGOSS_PATH=${GITHUB_WORKSPACE}/tools/dgoss \
bakery dgoss run \
Expand All @@ -229,3 +254,22 @@ jobs:
--dev-versions "$DEV_VERSIONS" \
--matrix-versions "$MATRIX_VERSIONS" \
--context "$BAKERY_CONTEXT"

build-test-result:
name: Build/Test result
needs: build-test
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Report
env:
RESULT: ${{ needs.build-test.result }}
run: |
# 'skipped' (empty matrix) and 'success' both pass; only real failures block.
if [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then
echo "build-test result: $RESULT"
exit 1
fi
echo "build-test result: $RESULT (ok)"
30 changes: 30 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,36 @@ Do not backport cosmetic changes, new feature additions, or non-security depende

- **Version format mismatch.** Product repos dispatch with raw git-describe versions (e.g., `v2026.03.0-473-g072bb6fd1f`). Bakery normalizes these to semver-with-metadata (e.g., `2026.03.0-dev+473-g072bb6fd1f`). If `bakery ci matrix` produces an empty matrix after a dispatch, the formats did not align. The shared workflows strip a leading `v` automatically. Check the rest of the version string against bakery's normalization.

## Change-aware PR builds

`bakery ci matrix` supports `--base-ref <ref>` and `--changed-files-from <file|->` to emit
only the matrix entries affected by a PR's changed files. This keeps PR CI fast: a template
change builds only the images whose templates changed; a version-directory change builds only
that version.

**This filtering applies to PR builds only.** Push-to-main, scheduled, and release builds
call `bakery ci matrix` without `--base-ref`, so they always build the full matrix.

### Classification rules

After ignoring Markdown (`*.md`) paths, changed files are classified as follows:

| Changed path | What builds |
|---|---|
| `bakery.yaml` / `bakery.yml` or `.github/workflows/**` | Full matrix (fail-safe) |
| `<image>/template/**` | That image's dev versions (if any are declared) |
| `<image>/<version-subpath>/**` | That release version only |
| An image-root file or unrecognized subdir under an image | That image's release versions (fail-safe) |
| Matrix image (e.g. `connect-content`) | Latest matrix slice + dev versions if declared |
| Anything else not attributable and not ignored | Full matrix (fail-safe) |

### Branch protection

Because per-image build jobs may not run on docs-only PRs, branch protection rules should
require the always-green **Build/Test result** gate job rather than individual build jobs.
The gate job is defined in `bakery-build-pr.yml` and always runs, even when no images need
to be built.

## Diagnose a build failure

**1. Find the failing run:**
Expand Down
144 changes: 128 additions & 16 deletions posit-bakery/posit_bakery/cli/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import json
import logging
import re
import subprocess
import sys
import python_on_whales
from enum import Enum
from pathlib import Path
Expand All @@ -11,8 +13,10 @@

from posit_bakery.cli.common import with_verbosity_flags, parse_dev_spec
from posit_bakery.config import BakeryConfig
from posit_bakery.config.changeset import classify_changes, ImageChangeSet, MatrixSelection
from posit_bakery.config.config import BakerySettings, BakeryConfigFilter, version_matches
from posit_bakery.config.image.posit_product.const import ReleaseChannelEnum
from posit_bakery.config.image.version import ImageVersion
from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum
from posit_bakery.log import stderr_console, stdout_console
from posit_bakery.registry_management.dockerhub.readme import push_readmes
Expand All @@ -34,6 +38,54 @@ class BakeryCIMatrixFieldEnum(str, Enum):
PLATFORM = "platform"


def _resolve_changed_files(base_ref: str | None, changed_files_from: str | None, rebase_root: Path) -> list[str] | None:
"""Return the changed-file list for change-aware filtering, or None to disable it.

``--changed-files-from`` takes precedence over ``--base-ref``. Git paths are
relative to the repo root; they are rebased onto ``rebase_root`` and paths
outside the root are dropped.
"""
if changed_files_from is not None:
if changed_files_from == "-":
raw = sys.stdin.read()
else:
raw = Path(changed_files_from).read_text()
return [line.strip() for line in raw.splitlines() if line.strip()]

if base_ref is None:
return None

# Local import: git diff is only needed on this rarely-used code path.
from posit_bakery.config.changeset import git_changed_files

toplevel = subprocess.run(
["git", "-C", str(rebase_root), "rev-parse", "--show-toplevel"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
repo_root = Path(toplevel)
rebased: list[str] = []
for rel in git_changed_files(repo_root, base_ref):
abs_path = repo_root / rel
try:
rebased.append((abs_path.relative_to(rebase_root)).as_posix())
except ValueError:
# Changed file lives outside this bakery context (monorepo) -> not our concern.
continue
return rebased


def _version_selected(ver: ImageVersion, cs: ImageChangeSet) -> bool:
"""Whether a candidate version is wanted by a change set."""
if getattr(ver, "isDevelopmentVersion", False):
return cs.include_dev
if getattr(ver, "isMatrixVersion", False):
return cs.include_matrix_latest and bool(getattr(ver, "latest", False))
# Plain release version.
return cs.include_all_release or ver.name in cs.versions


@app.command()
@with_verbosity_flags
def matrix(
Expand Down Expand Up @@ -101,6 +153,27 @@ def matrix(
callback=parse_dev_spec,
),
] = None,
base_ref: Annotated[
Optional[str],
typer.Option(
"--base-ref",
envvar="BAKERY_BASE_REF",
show_default=False,
help="Git ref to diff against (merge-base) to build only changed images/versions. "
"When unset, the full matrix is emitted.",
rich_help_panel=RichHelpPanelEnum.FILTERS,
),
] = None,
changed_files_from: Annotated[
Optional[str],
typer.Option(
"--changed-files-from",
show_default=False,
help="Read changed file paths (one per line; '-' for stdin) instead of running git diff. "
"Overrides --base-ref.",
rich_help_panel=RichHelpPanelEnum.FILTERS,
),
] = None,
) -> None:
"""Generates a JSON matrix of image versions for CI workflows to consume

Expand Down Expand Up @@ -135,27 +208,66 @@ def matrix(
c = BakeryConfig.from_context(context=context, settings=settings)
images = [i for i in c.model.images if image_name is None or re.search(image_name, i.name) is not None]

selection: MatrixSelection | None = None
changed = _resolve_changed_files(base_ref, changed_files_from, c.base_path)
if changed is not None:
selection = classify_changes(c, changed)
if selection.full:
# Fail-safe / repo-wide change: behave exactly as a full matrix.
selection = None
else:
log.info(
"Change-aware matrix: %s",
{name: vars(cs) for name, cs in selection.images.items()} or "no affected images",
)

data = []
for img in images:
entry = {"image": img.name}
versions = img.versions
if img.matrix is None and matrix_versions == MatrixVersionInclusionEnum.ONLY:
if selection is not None and img.name not in selection.images:
continue
elif img.matrix is not None:
if matrix_versions != MatrixVersionInclusionEnum.EXCLUDE:
if dev_versions == DevVersionInclusionEnum.ONLY:
pass # img.versions has dev versions; matrix prod versions all fail the dev filter
elif dev_versions == DevVersionInclusionEnum.INCLUDE:
dev_versions_loaded = [v for v in img.versions if v.isDevelopmentVersion]
versions = img.matrix.to_image_versions() + dev_versions_loaded
else:
versions = img.matrix.to_image_versions()
# If EXCLUDE: fall through using img.versions (devVersions are appended
# there by load_dev_versions). The dev_versions filter below handles the rest.
cs = selection.images[img.name] if selection is not None else None

entry = {"image": img.name}

if cs is not None:
# Change-aware: build candidates purely from this image's change set.
# _version_selected() gates each candidate by type (release / dev /
# matrix-latest), so the list only needs to *contain* each wanted
# version exactly once.
if cs.include_dev and dev_versions == DevVersionInclusionEnum.EXCLUDE:
# Dev versions are not loaded under the EXCLUDE baseline; load them
# now. (Under INCLUDE/ONLY, BakeryConfig already loaded them at
# config time, so loading again would duplicate entries.)
img.load_dev_versions()
versions = list(img.versions)
if img.matrix is not None:
versions = versions + img.matrix.to_image_versions()
else:
# Full-matrix path (no change-awareness). Preserves the matrix+dev
# filtering fix (commit 92c72833 / generate_image_targets): when matrix
# versions are included, fold the already-loaded dev versions into the
# matrix product per the dev_versions setting so they survive the
# matches_dev_filter check below.
versions = list(img.versions)
if img.matrix is None and matrix_versions == MatrixVersionInclusionEnum.ONLY:
continue
elif img.matrix is not None:
if matrix_versions != MatrixVersionInclusionEnum.EXCLUDE:
if dev_versions == DevVersionInclusionEnum.ONLY:
pass # img.versions has dev versions; matrix prod versions all fail the dev filter
elif dev_versions == DevVersionInclusionEnum.INCLUDE:
dev_versions_loaded = [v for v in img.versions if v.isDevelopmentVersion]
versions = img.matrix.to_image_versions() + dev_versions_loaded
else:
versions = img.matrix.to_image_versions()

for ver in versions:
included, _ = ver.matches_dev_filter(dev_versions, dev_channel)
if not included:
if cs is not None and not _version_selected(ver, cs):
continue
if cs is None:
included, _ = ver.matches_dev_filter(dev_versions, dev_channel)
if not included:
continue
if image_version is not None and not version_matches(ver.name, image_version):
continue

Expand Down
Loading
Loading