diff --git a/.github/workflows/bakery-build-native.yml b/.github/workflows/bakery-build-native.yml index 0bdcff59..b77236b3 100644 --- a/.github/workflows/bakery-build-native.yml +++ b/.github/workflows/bakery-build-native.yml @@ -9,7 +9,7 @@ on: inputs: version: description: "The version of the Posit Bakery tool to install" - default: "main" + default: "feat/dry-run-artifacts" required: false type: string context: @@ -38,10 +38,10 @@ on: required: false type: string push: - description: "Whether to push images to registries [default: false]" - default: false + description: "Push target [default: off] [options: off (no push), temp (multi-arch index to temp registry only), on (push to final registries)]" + default: "off" required: false - type: boolean + type: string retry: description: "Number of times to retry a failed build [default: 1]" default: 1 @@ -109,8 +109,17 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Validate push input + env: + PUSH: ${{ inputs.push }} + run: | + case "$PUSH" in + off|temp|on) ;; + *) echo "::error::Invalid push value '$PUSH'; expected off, temp, or on." ; exit 1 ;; + esac + - name: Install - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -147,7 +156,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -188,14 +197,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: ${{ inputs.push && steps.filter-steps.outputs.docker-hub == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.docker-hub == 'true' }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: "posit" password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Configure AWS Credentials - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: ${{ secrets.AWS_ROLE }} @@ -203,7 +212,7 @@ jobs: role-session-name: gha-bakery-build - name: Login to Amazon ECR - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2.1.5 - name: Normalize platform @@ -287,7 +296,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -320,14 +329,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: ${{ inputs.push && steps.filter-steps.outputs.docker-hub == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.docker-hub == 'true' }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: "posit" password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Configure AWS Credentials - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: ${{ secrets.AWS_ROLE }} @@ -335,7 +344,7 @@ jobs: role-session-name: gha-bakery-build - name: Login to Amazon ECR - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2.1.5 - name: Setup docker buildx @@ -361,18 +370,42 @@ jobs: REGISTRY: ghcr.io/${{ github.repository_owner }} PUSH: ${{ inputs.push }} run: | - if [ "$PUSH" = "true" ]; then PUSH_FLAG=""; else PUSH_FLAG="--dry-run"; fi + case "$PUSH" in + on) PUSH_FLAG="" ;; + temp) PUSH_FLAG="--index-only" ;; + *) PUSH_FLAG="--dry-run" ;; + esac bakery ci merge \ --context "$CONTEXT" \ --temp-registry "$REGISTRY" \ $PUSH_FLAG \ ./*-metadata.json + - name: Build Artifact Summary + if: ${{ inputs.push != 'off' }} + env: + CONTEXT: ${{ inputs.context }} + REGISTRY: ghcr.io/${{ github.repository_owner }} + PUSH: ${{ inputs.push }} + DEV_VERSIONS: ${{ inputs.dev-versions }} + MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + IMAGE_VERSION: ${{ inputs.image-version }} + DEV_STREAM: ${{ inputs.dev-stream }} + run: | + IMAGE_VERSION="${IMAGE_VERSION#v}" + if [ "$PUSH" = "on" ]; then MODE="final"; else MODE="temp"; fi + ARGS=(--mode "$MODE" --temp-registry "$REGISTRY" --context "$CONTEXT" + --dev-versions "$DEV_VERSIONS" --matrix-versions "$MATRIX_VERSIONS") + [[ -n "$IMAGE_VERSION" ]] && ARGS+=(--image-version "$IMAGE_VERSION") + [[ -n "$DEV_STREAM" ]] && ARGS+=(--dev-stream "$DEV_STREAM") + bakery ci summary "${ARGS[@]}" --output summary.md + cat summary.md >> "$GITHUB_STEP_SUMMARY" + readme: name: Push READMEs permissions: contents: read - if: ${{ inputs.push }} + if: ${{ inputs.push == 'on' }} needs: - merge runs-on: ubuntu-latest @@ -382,7 +415,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/bakery-build-pr.yml b/.github/workflows/bakery-build-pr.yml index 3810130a..28c4c431 100644 --- a/.github/workflows/bakery-build-pr.yml +++ b/.github/workflows/bakery-build-pr.yml @@ -1,8 +1,13 @@ # Fork-safe PR build workflow for Posit container images. # -# Unlike bakery-build-native.yml this workflow never pushes images. -# Fork PRs get a read-only GITHUB_TOKEN with no access to repo secrets, -# so registry logins and caching are conditional on the PR source. +# Fork PRs stay load-only and never push: they get a read-only GITHUB_TOKEN +# with no access to repo secrets, so registry logins and caching are skipped. +# Same-repo PRs push a temporary multi-arch index to ghcr.io/ (by +# digest per-platform, then assembled with `bakery ci merge --index-only`) so +# maintainers can pull and debug the built image when tests fail. PRs never +# push to the final/release registries. +# +# Registry logins and caching are conditional on the PR source. # # Security policy: No ${{ }} expressions in `run:` blocks. # All expression values are assigned to `env:` and referenced as @@ -17,7 +22,7 @@ on: inputs: version: description: "Bakery version to install" - default: "main" + default: "feat/dry-run-artifacts" required: false type: string context: @@ -92,7 +97,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -141,7 +146,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -191,41 +196,151 @@ jobs: DEV_VERSIONS: ${{ inputs.dev-versions }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} BAKERY_CONTEXT: ${{ inputs.context }} - REGISTRY_OWNER: ${{ github.repository_owner }} + REGISTRY: ghcr.io/${{ github.repository_owner }} + NORMALIZED_PLATFORM: ${{ steps.normalize-platform.outputs.platform }} CACHE: ${{ inputs.cache }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Cache only for same-repo PRs when enabled; fork PRs have no + # registry credentials, so they never receive cache flags. CACHE_FLAGS=() if [ "$IS_FORK" != "true" ] && [ "$CACHE" = "true" ]; then - CACHE_FLAGS=(--cache-registry "ghcr.io/${REGISTRY_OWNER}") + CACHE_FLAGS=(--cache-registry "$REGISTRY") + fi + if [ "$IS_FORK" = "true" ]; then + bakery build \ + --strategy build --pull --load \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --image-platform "$IMAGE_PLATFORM" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + "${CACHE_FLAGS[@]}" \ + --context "$BAKERY_CONTEXT" + else + bakery build \ + --strategy build --pull --push \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --image-platform "$IMAGE_PLATFORM" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + "${CACHE_FLAGS[@]}" \ + --temp-registry "$REGISTRY" \ + --metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json" \ + --context "$BAKERY_CONTEXT" fi - bakery build \ - --strategy build --pull --load \ - --retry "$RETRY" \ - --image-name "^${IMAGE_NAME}$" \ - --image-version "$IMAGE_VERSION" \ - --image-platform "$IMAGE_PLATFORM" \ - --dev-versions "$DEV_VERSIONS" \ - --matrix-versions "$MATRIX_VERSIONS" \ - "${CACHE_FLAGS[@]}" \ - --context "$BAKERY_CONTEXT" - name: Test env: + IS_FORK: ${{ needs.detect.outputs.is-fork }} IMAGE_NAME: ${{ matrix.img.image }} IMAGE_VERSION: ${{ matrix.img.version }} IMAGE_PLATFORM: ${{ matrix.img.platform }} DEV_VERSIONS: ${{ inputs.dev-versions }} MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + NORMALIZED_PLATFORM: ${{ steps.normalize-platform.outputs.platform }} BAKERY_CONTEXT: ${{ inputs.context }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + DGOSS_ARGS=( + --image-name "^${IMAGE_NAME}$" + --image-version "$IMAGE_VERSION" + --image-platform "$IMAGE_PLATFORM" + --dev-versions "$DEV_VERSIONS" + --matrix-versions "$MATRIX_VERSIONS" + --context "$BAKERY_CONTEXT" + ) + if [ "$IS_FORK" != "true" ]; then + # Same-repo PRs pushed by digest; resolve via the build metadata. + DGOSS_ARGS+=(--metadata-file "./${IMAGE_NAME}-${IMAGE_VERSION}-${NORMALIZED_PLATFORM}-metadata.json") + fi GOSS_PATH=${GITHUB_WORKSPACE}/tools/goss \ DGOSS_PATH=${GITHUB_WORKSPACE}/tools/dgoss \ - bakery run dgoss \ - --image-name "^${IMAGE_NAME}$" \ - --image-version "$IMAGE_VERSION" \ - --image-platform "$IMAGE_PLATFORM" \ + bakery run dgoss "${DGOSS_ARGS[@]}" + + - name: Upload Metadata + if: needs.detect.outputs.is-fork != 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: "${{ matrix.img.image }}-${{ matrix.img.version }}-${{ steps.normalize-platform.outputs.platform }}-metadata" + path: "./${{ matrix.img.image }}-${{ matrix.img.version }}-${{ steps.normalize-platform.outputs.platform }}-metadata.json" + overwrite: 'true' + + merge: + name: "Merge (temp index)" + if: ${{ needs.detect.outputs.is-fork != 'true' }} + permissions: + contents: read + packages: write + needs: + - detect + - build-test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup bakery + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" + with: + version: ${{ inputs.version }} + + - name: Set up Docker + uses: docker/setup-docker-action@b2189fbf2a6592b51fee7cdd93ee2bfaeba733db # v5.1.0 + with: + daemon-config: | + { + "features": { + "containerd-snapshotter": true + } + } + + - name: Login to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup docker buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + + - name: Setup ORAS CLI + uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 + + - name: Download Metadata + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: "*-metadata" + merge-multiple: true + + - name: Merge index (temp) + env: + BAKERY_CONTEXT: ${{ inputs.context }} + REGISTRY_OWNER: ${{ github.repository_owner }} + run: | + bakery ci merge \ + --context "$BAKERY_CONTEXT" \ + --temp-registry "ghcr.io/${REGISTRY_OWNER}" \ + --index-only \ + ./*-metadata.json + + - name: Build Artifact Summary + env: + BAKERY_CONTEXT: ${{ inputs.context }} + REGISTRY_OWNER: ${{ github.repository_owner }} + DEV_VERSIONS: ${{ inputs.dev-versions }} + MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + run: | + bakery ci summary \ + --mode temp \ + --temp-registry "ghcr.io/${REGISTRY_OWNER}" \ + --context "$BAKERY_CONTEXT" \ --dev-versions "$DEV_VERSIONS" \ --matrix-versions "$MATRIX_VERSIONS" \ - --context "$BAKERY_CONTEXT" + --output summary.md + cat summary.md >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/bakery-build.yml b/.github/workflows/bakery-build.yml index a0cf09fa..e4fe7b44 100644 --- a/.github/workflows/bakery-build.yml +++ b/.github/workflows/bakery-build.yml @@ -10,7 +10,7 @@ on: inputs: version: description: "The version of the Posit Bakery tool to install" - default: "main" + default: "feat/dry-run-artifacts" required: false type: string context: @@ -39,10 +39,10 @@ on: required: false type: string push: - description: "Whether to push images to registries [default: false]" - default: false + description: "Push target [default: off] [options: off (no push), temp (multi-arch tag to temp registry only), on (push to final registries)]" + default: "off" required: false - type: boolean + type: string retry: description: "Number of times to retry a failed build [default: 1]" default: 1 @@ -100,8 +100,17 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Validate push input + env: + PUSH: ${{ inputs.push }} + run: | + case "$PUSH" in + off|temp|on) ;; + *) echo "::error::Invalid push value '$PUSH'; expected off, temp, or on." ; exit 1 ;; + esac + - name: Install - uses: "posit-dev/images-shared/setup-bakery@main" + uses: "posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -138,7 +147,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -167,14 +176,14 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Login to Docker Hub - if: ${{ inputs.push && steps.filter-steps.outputs.docker-hub == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.docker-hub == 'true' }} uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: "posit" password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Configure AWS Credentials - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/configure-aws-credentials@d979d5b3a71173a29b74b5b88418bfda9437d885 # v6.1.1 with: role-to-assume: ${{ secrets.AWS_ROLE }} @@ -182,7 +191,7 @@ jobs: role-session-name: gha-bakery-build - name: Login to Amazon ECR - if: ${{ inputs.push && steps.filter-steps.outputs.ecr == 'true' }} + if: ${{ inputs.push == 'on' && steps.filter-steps.outputs.ecr == 'true' }} uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2.1.5 - name: Setup docker buildx @@ -199,22 +208,35 @@ jobs: REGISTRY: ghcr.io/${{ github.repository_owner }} CONTEXT: ${{ inputs.context }} CACHE: ${{ inputs.cache }} + PUSH: ${{ inputs.push }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | CACHE_FLAGS=() if [ "$CACHE" = "true" ]; then CACHE_FLAGS=(--cache-registry "$REGISTRY") fi - bakery build --load --pull \ - --retry "$RETRY" \ - --image-name "^${IMAGE_NAME}$" \ - --image-version "$IMAGE_VERSION" \ - --dev-versions "$DEV_VERSIONS" \ - --matrix-versions "$MATRIX_VERSIONS" \ - "${CACHE_FLAGS[@]}" \ - --context "$CONTEXT" + if [ "$PUSH" = "temp" ]; then + bakery build --push --temp-registry "$REGISTRY" --temp-tagged --pull \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + "${CACHE_FLAGS[@]}" \ + --context "$CONTEXT" + else + bakery build --load --pull \ + --retry "$RETRY" \ + --image-name "^${IMAGE_NAME}$" \ + --image-version "$IMAGE_VERSION" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + "${CACHE_FLAGS[@]}" \ + --context "$CONTEXT" + fi - name: Test + if: ${{ inputs.push != 'temp' }} env: IMAGE_NAME: ${{ matrix.img.image }} IMAGE_VERSION: ${{ matrix.img.version }} @@ -236,7 +258,7 @@ jobs: # Since this is a reusable workflow, we need to be very explicit about # when to push the images. We default to not pushing images, so the # calling workflow must explicitly request pushing images. - if: ${{ inputs.push }} + if: ${{ inputs.push == 'on' }} env: GIT_SHA: ${{ github.sha }} RETRY: ${{ inputs.retry }} @@ -255,11 +277,33 @@ jobs: --matrix-versions "$MATRIX_VERSIONS" \ --context "$CONTEXT" + - name: Build Artifact Summary + if: ${{ inputs.push != 'off' }} + env: + REGISTRY: ghcr.io/${{ github.repository_owner }} + PUSH: ${{ inputs.push }} + CONTEXT: ${{ inputs.context }} + IMAGE_NAME: ${{ matrix.img.image }} + IMAGE_VERSION: ${{ matrix.img.version }} + DEV_VERSIONS: ${{ inputs.dev-versions }} + MATRIX_VERSIONS: ${{ inputs.matrix-versions }} + run: | + if [ "$PUSH" = "on" ]; then MODE="final"; else MODE="temp"; fi + bakery ci summary \ + --mode "$MODE" \ + --temp-registry "$REGISTRY" \ + --context "$CONTEXT" \ + --image-version "$IMAGE_VERSION" \ + --dev-versions "$DEV_VERSIONS" \ + --matrix-versions "$MATRIX_VERSIONS" \ + --output summary.md + cat summary.md >> "$GITHUB_STEP_SUMMARY" + readme: name: Push READMEs permissions: contents: read - if: ${{ inputs.push }} + if: ${{ inputs.push == 'on' }} needs: - build runs-on: ubuntu-latest @@ -269,7 +313,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/clean.yml b/.github/workflows/clean.yml index ea47db70..2042855d 100644 --- a/.github/workflows/clean.yml +++ b/.github/workflows/clean.yml @@ -86,7 +86,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} @@ -131,7 +131,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/hadolint.yml b/.github/workflows/hadolint.yml index 4d4295c2..4d64a9d2 100644 --- a/.github/workflows/hadolint.yml +++ b/.github/workflows/hadolint.yml @@ -40,7 +40,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@feat/dry-run-artifacts" with: version: ${{ inputs.version }} diff --git a/.github/workflows/product-release.yml b/.github/workflows/product-release.yml index 21341e2e..a58f70cd 100644 --- a/.github/workflows/product-release.yml +++ b/.github/workflows/product-release.yml @@ -45,7 +45,7 @@ jobs: token: ${{ steps.app-token.outputs.token }} - name: Install bakery - uses: posit-dev/images-shared/setup-bakery@main + uses: posit-dev/images-shared/setup-bakery@feat/dry-run-artifacts - name: Parse version id: parse diff --git a/posit-bakery/posit_bakery/cli/build.py b/posit-bakery/posit_bakery/cli/build.py index d8449e38..7ef1379f 100644 --- a/posit-bakery/posit_bakery/cli/build.py +++ b/posit-bakery/posit_bakery/cli/build.py @@ -133,6 +133,14 @@ def build( rich_help_panel="Build Configuration & Outputs", ), ] = None, + temp_tagged: Annotated[ + bool, + typer.Option( + help="Push a single multi-arch tag to the temp registry (tmp:{uid}) instead of by digest. " + "Requires --temp-registry and --push.", + rich_help_panel="Build Configuration & Outputs", + ), + ] = False, image_name: Annotated[ Optional[str], typer.Option( @@ -217,6 +225,7 @@ def build( clean_temporary=clean, cache_registry=cache_registry, temp_registry=temp_registry, + temp_tagged=temp_tagged, ) config: BakeryConfig = BakeryConfig.from_context(context, settings) diff --git a/posit-bakery/posit_bakery/cli/ci.py b/posit-bakery/posit_bakery/cli/ci.py index eae6af4c..ab9d94f4 100644 --- a/posit-bakery/posit_bakery/cli/ci.py +++ b/posit-bakery/posit_bakery/cli/ci.py @@ -21,12 +21,39 @@ log = logging.getLogger(__name__) +def render_artifact_summary(targets, mode: str) -> str: + """Render a GitHub-flavored markdown table of image references for the job summary. + + :param targets: Iterable of ImageTarget. + :param mode: ``temp`` to report the stable temp index ref (``temp_tag_name``), + ``final`` to report each final destination tag. + :return: Markdown string. + """ + title = "Build Artifacts (temporary registry)" if mode == "temp" else "Build Artifacts (pushed)" + lines = [f"## {title}", "", "| Image | Version | Reference | Pull |", "|---|---|---|---|"] + + for target in targets: + if mode == "temp": + refs = [target.temp_tag_name] if target.temp_tag_name else [] + else: + refs = target.tags.as_strings() + for ref in refs: + lines.append(f"| {target.image_name} | {target.image_version.name} | `{ref}` | `docker pull {ref}` |") + + return "\n".join(lines) + + class RichHelpPanelEnum(str, Enum): """Enum for categorizing options into rich help panels.""" FILTERS = "Filters" +class SummaryModeEnum(str, Enum): + TEMP = "temp" + FINAL = "final" + + class BakeryCIMatrixFieldEnum(str, Enum): VERSION = "version" DEV = "dev" @@ -169,6 +196,14 @@ def merge( dry_run: Annotated[ bool, typer.Option(help="If set, the merged images will not be pushed to the registry.") ] = False, + index_only: Annotated[ + bool, + typer.Option( + help="Create and push the multi-arch index to the temp registry but do not copy it to " + "the final destination tags. Useful for publishing debug artifacts on non-release builds.", + rich_help_panel="Build Configuration & Outputs", + ), + ] = False, dev_stream: Annotated[ Optional[ReleaseStreamEnum], typer.Option( @@ -235,15 +270,22 @@ def merge( from posit_bakery.plugins.registry import get_plugin oras = get_plugin("oras") - results = oras.execute(config.base_path, config.targets, dry_run=dry_run) + results = oras.execute(config.base_path, config.targets, dry_run=dry_run, index_only=index_only) - # CI-specific: verify final manifests with imagetools inspect + # CI-specific: verify the pushed manifest 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]) + if not workflow_result: + continue + ref = ( + workflow_result.temp_index_ref + if index_only + else (workflow_result.destinations[0] if workflow_result.destinations else None) + ) + if ref: + manifest = python_on_whales.docker.buildx.imagetools.inspect(ref) stdout_console.print_json(manifest.model_dump_json(indent=2, exclude_unset=True, exclude_none=True)) oras.results(results) @@ -312,3 +354,81 @@ def readme( stderr_console.print(f"✅ Pushed {count} README(s) to Docker Hub", style="success") else: stderr_console.print("No READMEs pushed", style="dim") + + +@app.command() +@with_verbosity_flags +def summary( + mode: Annotated[ + SummaryModeEnum, + typer.Option(help="Which references to report: 'temp' (temp registry index) or 'final' (pushed tags)."), + ] = SummaryModeEnum.TEMP, + context: Annotated[ + Path, + typer.Option( + exists=True, + file_okay=False, + dir_okay=True, + readable=True, + resolve_path=True, + help="The root path to use. Defaults to the current working directory where invoked.", + ), + ] = auto_path(), + temp_registry: Annotated[ + Optional[str], + typer.Option(help="Temporary registry used for temp refs (required for --mode temp)."), + ] = None, + dev_versions: Annotated[ + Optional[DevVersionInclusionEnum], + typer.Option( + help="Include or exclude development versions defined in config.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = DevVersionInclusionEnum.EXCLUDE, + dev_stream: Annotated[ + Optional[ReleaseStreamEnum], + typer.Option( + help="Filter development versions to a specific release stream.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + matrix_versions: Annotated[ + Optional[MatrixVersionInclusionEnum], + typer.Option( + help="Include or exclude versions defined in image matrix.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = MatrixVersionInclusionEnum.EXCLUDE, + image_version: Annotated[ + Optional[str], + typer.Option( + show_default=False, + help="The image version to filter to.", + rich_help_panel=RichHelpPanelEnum.FILTERS, + ), + ] = None, + output: Annotated[ + Optional[Path], + typer.Option(writable=True, help="Write the markdown summary to this path instead of stdout."), + ] = None, +) -> None: + """Render a markdown table of build artifact image references for a CI job summary.""" + if mode == SummaryModeEnum.TEMP and not temp_registry: + log.error("--temp-registry is required when --mode temp.") + raise typer.Exit(code=1) + + settings = BakerySettings( + filter=BakeryConfigFilter(image_version=image_version), + dev_versions=dev_versions, + dev_stream=dev_stream, + matrix_versions=matrix_versions, + temp_registry=temp_registry, + ) + config: BakeryConfig = BakeryConfig.from_context(context, settings) + + md = render_artifact_summary(config.targets, mode=mode.value) + + if output is not None: + output.write_text(md + "\n") + else: + stdout_console.print(md) diff --git a/posit-bakery/posit_bakery/config/config.py b/posit-bakery/posit_bakery/config/config.py index be58e5af..29f72d83 100644 --- a/posit-bakery/posit_bakery/config/config.py +++ b/posit-bakery/posit_bakery/config/config.py @@ -326,6 +326,14 @@ class BakerySettings(BaseModel): ] cache_registry: Annotated[str | None, Field(description="Registry to use for image build cache.", default=None)] temp_registry: Annotated[str | None, Field(description="Registry to use for image build temp cache.", default=None)] + temp_tagged: Annotated[ + bool, + Field( + description="When pushing to the temp registry, push a single multi-arch tag " + "({temp_registry}/{image_name}/tmp:{uid}) instead of pushing by digest.", + default=False, + ), + ] class BakeryConfig: @@ -888,7 +896,9 @@ def generate_image_targets(self, settings: BakerySettings = BakerySettings()): image_variant=variant, image_os=_os, settings=ImageTargetSettings( - temp_registry=settings.temp_registry, cache_registry=settings.cache_registry + temp_registry=settings.temp_registry, + temp_tagged=settings.temp_tagged, + cache_registry=settings.cache_registry, ), ) ) @@ -993,7 +1003,7 @@ def build_targets( context=self.base_path, image_targets=self.targets, platforms=platforms, push=push ) set_opts = None - if self.settings.temp_registry is not None and push: + if self.settings.temp_registry is not None and push and not self.settings.temp_tagged: set_opts = {"*.output": {"type": "image", "push-by-digest": True, "name-canonical": True, "push": True}} _retry_build( lambda: bake_plan.build( diff --git a/posit-bakery/posit_bakery/image/bake/bake.py b/posit-bakery/posit_bakery/image/bake/bake.py index 18c464e6..5bb277ec 100644 --- a/posit-bakery/posit_bakery/image/bake/bake.py +++ b/posit-bakery/posit_bakery/image/bake/bake.py @@ -135,7 +135,9 @@ def from_image_target( } ] - if image_target.temp_name is not None: + if image_target.settings.temp_tagged and image_target.temp_tag_name is not None: + kwargs["tags"] = [image_target.temp_tag_name] + elif image_target.temp_name is not None: kwargs["tags"] = [image_target.temp_name.rsplit(":", 1)[0]] secrets = [s.as_bake_json() for s in image_target.resolved_build_secrets] diff --git a/posit-bakery/posit_bakery/image/image_target.py b/posit-bakery/posit_bakery/image/image_target.py index 289385f4..77455737 100644 --- a/posit-bakery/posit_bakery/image/image_target.py +++ b/posit-bakery/posit_bakery/image/image_target.py @@ -212,6 +212,13 @@ class ImageTargetSettings(BaseModel): str | None, Field(default=None, description="Temporary registry to use for multiplatform split/merge builds."), ] + temp_tagged: Annotated[ + bool, + Field( + default=False, + description="When True, push a single multi-arch tag to the temp registry instead of pushing by digest.", + ), + ] cache_registry: Annotated[ str | None, Field(default=None, description="Registry to use for build cache storage and retrieval."), @@ -553,6 +560,14 @@ def temp_name(self) -> str | None: return f"{self.settings.temp_registry}/{self.image_name}/tmp" + @property + def temp_tag_name(self) -> str | None: + """Generate a stable, human-pullable temp tag ({temp_registry}/{image_name}/tmp:{uid}) for temporary image storage in multiplatform split/merge builds.""" + if not self.settings.temp_registry: + return None + + return f"{self.settings.temp_registry}/{self.image_name}/tmp:{self.uid}" + @property def resolved_build_secrets(self) -> list[BuildSecret]: """Return the parent Image's BuildSecrets whose envVar is set in the environment. @@ -644,7 +659,10 @@ def build( tags = self.tags.as_strings() output = {} - if self.temp_name is not None: + if self.settings.temp_tagged and self.temp_tag_name is not None: + # Push a single multi-arch tag to the temp registry instead of by digest. + tags = [self.temp_tag_name] + elif self.temp_name is not None: tags = [self.temp_name] # If push is true for a temporary image, override the output to push the image by digest. if push: diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py b/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py index 414338ef..c7b92588 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/__init__.py @@ -102,6 +102,7 @@ def execute( targets: list[ImageTarget], *, dry_run: bool = False, + index_only: bool = False, **kwargs, ) -> list[ToolCallResult]: """Execute ORAS merge workflow against the given image targets.""" @@ -131,7 +132,7 @@ def execute( log.info(f"Merging sources for image UID '{target.uid}'") workflow = OrasMergeWorkflow.from_image_target(target) - workflow_result = workflow.run(dry_run=dry_run) + workflow_result = workflow.run(dry_run=dry_run, index_only=index_only) results.append( ToolCallResult( diff --git a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py index 8032bc0d..ae050702 100644 --- a/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py +++ b/posit-bakery/posit_bakery/plugins/builtin/oras/oras.py @@ -142,24 +142,6 @@ def command(self) -> list[str]: return cmd -class OrasManifestDelete(OrasCommand): - """Delete a manifest from a registry. - - This command deletes a manifest (image or index) from a registry. - """ - - reference: Annotated[str, Field(description="The manifest reference to delete.")] - - @property - def command(self) -> list[str]: - """Build the oras manifest delete command.""" - cmd = [self.oras_bin, "manifest", "delete", "--force"] - if self.plain_http: - cmd.append("--plain-http") - cmd.append(self.reference) - return cmd - - class OrasMergeWorkflowResult(BaseModel): """Result of an ORAS merge workflow execution.""" @@ -175,7 +157,9 @@ class OrasMergeWorkflow(BaseModel): This workflow: 1. Creates a temporary manifest index from platform-specific source images 2. Copies the index to all target registries/tags - 3. Deletes the temporary index + + The temporary index is left in place and is cleaned up out-of-band by the + ``clean.yml`` workflow (``bakery clean temp-registry``) rather than deleted here. """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -205,67 +189,68 @@ def temp_index_tag(self) -> str: f"{self.image_target.temp_registry}/{self.image_target.image_name}/tmp:{self.image_target.uid}{source_hash}" ) - def run(self, dry_run: bool = False) -> OrasMergeWorkflowResult: + def run(self, dry_run: bool = False, index_only: bool = False) -> OrasMergeWorkflowResult: """Run the merge workflow. :param dry_run: If True, log commands without executing them. + :param index_only: If True, create and push the multi-arch index to the stable + temp tag (``temp_tag_name``) but skip copying it to the final destinations. :return: Result of the workflow execution. """ + if index_only and self.image_target.temp_tag_name is None: + raise ValueError("index_only mode requires temp_registry to be configured.") + + index_dest = self.image_target.temp_tag_name if index_only else self.temp_index_tag + log.info(f"Starting ORAS merge workflow for {self.image_target.image_name}") log.debug(f"Sources: {self.sources}") - log.debug(f"Temporary index: {self.temp_index_tag}") - log.debug(f"Destinations: {', '.join(self.image_target.tags.as_strings())}") + log.debug(f"Index: {index_dest}") + if not index_only: + log.debug(f"Destinations: {', '.join(self.image_target.tags.as_strings())}") try: # Step 1: Create the manifest index - log.info(f"Creating manifest index at {self.temp_index_tag}") + log.info(f"Creating manifest index at {index_dest}") create_cmd = OrasManifestIndexCreate( oras_bin=self.oras_bin, sources=self.image_target.get_merge_sources(), - destination=self.temp_index_tag, + destination=index_dest, annotations=self.annotations, plain_http=self.plain_http, ) create_cmd.run(dry_run=dry_run) - # Step 2: Copy to all destinations - for destination, tags in itertools.groupby(self.image_target.tags, lambda x: x.destination): - log.info(f"Copying index to {destination}") - combine_tag_str = destination + ":" + ",".join(tag.suffix for tag in tags) - copy_cmd = OrasCopy( - oras_bin=self.oras_bin, - source=self.temp_index_tag, - destination=combine_tag_str, - plain_http=self.plain_http, - ) - copy_cmd.run(dry_run=dry_run) - - # Step 3: Delete the temporary index (non-fatal) - log.info(f"Cleaning up temporary index {self.temp_index_tag}") - delete_cmd = OrasManifestDelete( - oras_bin=self.oras_bin, - reference=self.temp_index_tag, - plain_http=self.plain_http, - ) - try: - delete_cmd.run(dry_run=dry_run) - except BakeryToolRuntimeError as e: - log.warning(f"Failed to clean up temporary index {self.temp_index_tag}: {e}") - + # Step 2: Copy to all destinations (skipped in index-only mode) + destinations: list[str] = [] + if not index_only: + for destination, tags in itertools.groupby(self.image_target.tags, lambda x: x.destination): + log.info(f"Copying index to {destination}") + combine_tag_str = destination + ":" + ",".join(tag.suffix for tag in tags) + copy_cmd = OrasCopy( + oras_bin=self.oras_bin, + source=index_dest, + destination=combine_tag_str, + plain_http=self.plain_http, + ) + copy_cmd.run(dry_run=dry_run) + destinations = self.image_target.tags.as_strings() + + # The temporary index is intentionally left in place; it is cleaned up + # out-of-band by the clean.yml workflow (bakery clean temp-registry). log.info(f"ORAS merge workflow completed successfully") return OrasMergeWorkflowResult( success=True, - temp_index_ref=self.temp_index_tag, - destinations=self.image_target.tags.as_strings(), + temp_index_ref=index_dest, + destinations=destinations, ) except BakeryToolRuntimeError as e: log.error(f"ORAS merge workflow failed: {e}") return OrasMergeWorkflowResult( success=False, - temp_index_ref=self.temp_index_tag, - destinations=self.image_target.tags.as_strings(), + temp_index_ref=index_dest, + destinations=[] if index_only else self.image_target.tags.as_strings(), error=str(e), ) diff --git a/posit-bakery/test/cli/test_ci.py b/posit-bakery/test/cli/test_ci.py index 4c2992ea..a425579a 100644 --- a/posit-bakery/test/cli/test_ci.py +++ b/posit-bakery/test/cli/test_ci.py @@ -1,13 +1,17 @@ import json import re +import shutil +from pathlib import Path from unittest.mock import MagicMock import pytest from pytest_bdd import scenarios, then, parsers, given +from posit_bakery.cli.ci import render_artifact_summary from posit_bakery.config.config import version_matches from posit_bakery.plugins.protocol import ToolCallResult +from test.cli.bakery_command import BakeryCommand scenarios( "cli/ci/matrix.feature", @@ -52,7 +56,8 @@ def patched_execute(base_path, targets, platform=None, **kwargs): if not sources: continue dry_run = kwargs.get("dry_run", False) - calls.append((sources, dry_run)) + index_only = kwargs.get("index_only", False) + calls.append((sources, dry_run, index_only)) results.append( ToolCallResult( exit_code=0, @@ -60,7 +65,7 @@ def patched_execute(base_path, targets, platform=None, **kwargs): target=target, stdout="", stderr="", - artifacts={"workflow_result": MagicMock(success=True, destinations=[])}, + artifacts={"workflow_result": MagicMock(success=True, destinations=[], temp_index_ref=None)}, ) ) return results @@ -100,6 +105,165 @@ def check_log_metadata_targets(bakery_command, datatable, ci_patched_merge_metho assert expected in calls +class TestMergeIndexOnly: + def test_merge_index_only_passes_flag(self, mocker, tmp_path): + """--index-only CLI flag should thread through to oras.execute as index_only=True.""" + calls = [] + + def patched_execute(base_path, targets, platform=None, **kwargs): + results = [] + for target in targets: + try: + sources = target.get_merge_sources() + except Exception: + continue + if not sources: + continue + dry_run = kwargs.get("dry_run", False) + index_only = kwargs.get("index_only", False) + calls.append((sources, dry_run, index_only)) + results.append( + ToolCallResult( + exit_code=0, + tool_name="oras", + target=target, + stdout="", + stderr="", + artifacts={"workflow_result": MagicMock(success=True, destinations=[], temp_index_ref=None)}, + ) + ) + return results + + mock_plugin = MagicMock() + mock_plugin.execute = patched_execute + mock_plugin.results = MagicMock() + mocker.patch("posit_bakery.plugins.registry.get_plugin", return_value=mock_plugin) + + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + # Copy the existing testdata metadata files so we get real targets loaded + testdata = Path(__file__).parent / "testdata" / "ci" / "merge" / "multiplatform" + for f in testdata.glob("*.json"): + shutil.copy(f, tmp_path / f.name) + + cmd = BakeryCommand() + cmd.context = resource + cmd.set_subcommand(["ci", "merge"]) + cmd.add_args(["--temp-registry", "ghcr.io/posit-dev", "--index-only"]) + cmd.add_args([str(p) for p in sorted(tmp_path.glob("*.json"))]) + cmd.run() + + assert cmd.result.exit_code == 0, cmd.result.output + # The fixture must have recorded at least one call (the testdata has 4 targets) + assert calls, "Expected at least one merge call to be recorded" + # Every recorded call must carry index_only=True + assert all(index_only for (_sources, _dry_run, index_only) in calls), ( + f"Some calls did not have index_only=True: {calls}" + ) + + +def _summary_mock_target(image_name, temp_tag, tag_strings): + t = MagicMock() + t.image_name = image_name + t.image_version.name = "1.0.0" + t.temp_tag_name = temp_tag + tags = MagicMock() + tags.as_strings.return_value = tag_strings + t.tags = tags + return t + + +def test_render_artifact_summary_temp(): + target = _summary_mock_target( + "connect", + "ghcr.io/posit-dev/connect/tmp:connect-1-0-0", + ["ghcr.io/posit-dev/connect:1.0.0"], + ) + md = render_artifact_summary([target], mode="temp") + + assert "Build Artifacts" in md + assert "ghcr.io/posit-dev/connect/tmp:connect-1-0-0" in md + assert "docker pull ghcr.io/posit-dev/connect/tmp:connect-1-0-0" in md + # temp mode must not advertise the final destination tag + assert "ghcr.io/posit-dev/connect:1.0.0" not in md + + +def test_render_artifact_summary_final(): + target = _summary_mock_target( + "connect", + "ghcr.io/posit-dev/connect/tmp:connect-1-0-0", + ["ghcr.io/posit-dev/connect:1.0.0", "docker.io/posit/connect:1.0.0"], + ) + md = render_artifact_summary([target], mode="final") + + assert "ghcr.io/posit-dev/connect:1.0.0" in md + assert "docker.io/posit/connect:1.0.0" in md + # final mode must not advertise the temp ref + assert "/tmp:connect-1-0-0" not in md + + +def test_summary_cli_temp_mode(tmp_path): + from typer.testing import CliRunner + from posit_bakery.cli.main import app + + runner = CliRunner() + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + out = tmp_path / "summary.md" + + result = runner.invoke( + app, + [ + "ci", + "summary", + "--mode", + "temp", + "--temp-registry", + "ghcr.io/posit-dev", + "--matrix-versions", + "include", + "--context", + str(resource), + "--output", + str(out), + ], + env={"TERM": "dumb", "NO_COLOR": "true"}, + ) + + assert result.exit_code == 0, result.output + body = out.read_text() + assert "Build Artifacts" in body + assert "test-multi" in body + assert "/tmp:" in body + assert "docker pull ghcr.io/posit-dev/test-multi/tmp:" in body + + +def test_summary_cli_temp_mode_requires_temp_registry(): + from typer.testing import CliRunner + from posit_bakery.cli.main import app + + runner = CliRunner() + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + result = runner.invoke( + app, + ["ci", "summary", "--mode", "temp", "--context", str(resource)], + env={"TERM": "dumb", "NO_COLOR": "true"}, + ) + assert result.exit_code == 1 + + +def test_summary_cli_invalid_mode_exits_nonzero(): + from typer.testing import CliRunner + from posit_bakery.cli.main import app + + runner = CliRunner() + resource = Path(__file__).parent.parent / "resources" / "multiplatform" + result = runner.invoke( + app, + ["ci", "summary", "--mode", "bogus", "--context", str(resource)], + env={"TERM": "dumb", "NO_COLOR": "true"}, + ) + assert result.exit_code != 0 + + class TestVersionMatches: @pytest.mark.parametrize( "ver_name,filter_version", diff --git a/posit-bakery/test/config/test_config.py b/posit-bakery/test/config/test_config.py index 19268970..7121e96f 100644 --- a/posit-bakery/test/config/test_config.py +++ b/posit-bakery/test/config/test_config.py @@ -1979,3 +1979,11 @@ def test_clean_temporary_dry_run( mock_ghcr_client.assert_called_once() mock_ghcr_client_instance.get_package_versions.assert_called_once() mock_ghcr_client_instance.delete_package_version.assert_not_called() + + +class TestBakerySettings: + def test_bakery_settings_temp_tagged_default(self): + assert BakerySettings().temp_tagged is False + + def test_bakery_settings_temp_tagged_set(self): + assert BakerySettings(temp_tagged=True).temp_tagged is True diff --git a/posit-bakery/test/image/bake/test_bake.py b/posit-bakery/test/image/bake/test_bake.py index 351d57be..46398078 100644 --- a/posit-bakery/test/image/bake/test_bake.py +++ b/posit-bakery/test/image/bake/test_bake.py @@ -6,6 +6,9 @@ import python_on_whales from pytest_mock import MockFixture +from posit_bakery.config import BakeryConfig +from posit_bakery.config.config import BakerySettings +from posit_bakery.const import DevVersionInclusionEnum, MatrixVersionInclusionEnum from posit_bakery.image.bake import BakePlan from posit_bakery.image.bake.bake import BakeTarget, BakeGroup from posit_bakery.image.image_target import ImageTargetSettings @@ -58,6 +61,25 @@ def _get_expected_plan(result_set: str, suite_name: str) -> Path: class TestBakeTarget: + def test_bake_target_uses_tagged_temp_ref_when_temp_tagged(self): + """Test that BakeTarget uses temp_tag_name when temp_tagged is set.""" + resources = Path(__file__).parents[2] / "resources" / "multiplatform" + settings = BakerySettings( + temp_registry="ghcr.io/posit-dev", + temp_tagged=True, + dev_versions=DevVersionInclusionEnum.INCLUDE, + matrix_versions=MatrixVersionInclusionEnum.INCLUDE, + ) + config = BakeryConfig.from_context(resources, settings) + assert config.targets, "multiplatform fixture produced no targets" + target = config.targets[0] + + bt = BakeTarget.from_image_target(target, push=True) + + assert bt.tags == [target.temp_tag_name] + # The tag must include a :uid suffix (not a bare /tmp ref). + assert ":" in bt.tags[0].rsplit("/", 1)[1] + def test_from_image_target(self, basic_standard_image_target): """Test that BakeTarget can be created from an ImageTarget.""" bake_target = BakeTarget.from_image_target(basic_standard_image_target) diff --git a/posit-bakery/test/image/test_image_target.py b/posit-bakery/test/image/test_image_target.py index 1fc0e0e0..65be120f 100644 --- a/posit-bakery/test/image/test_image_target.py +++ b/posit-bakery/test/image/test_image_target.py @@ -655,6 +655,15 @@ def test_temp_name(self, basic_standard_image_target): basic_standard_image_target.settings = ImageTargetSettings(temp_registry="ghcr.io/posit-dev") assert basic_standard_image_target.temp_name == "ghcr.io/posit-dev/test-image/tmp" + def test_temp_tag_name(self, basic_standard_image_target): + """Test the temp_tag_name property of an ImageTarget.""" + assert basic_standard_image_target.temp_tag_name is None + basic_standard_image_target.settings = ImageTargetSettings(temp_registry="ghcr.io/posit-dev") + assert ( + basic_standard_image_target.temp_tag_name + == f"ghcr.io/posit-dev/test-image/tmp:{basic_standard_image_target.uid}" + ) + @pytest.mark.build def test_build_args(self, basic_standard_image_target): """Test the build property of an ImageTarget.""" diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras.py b/posit-bakery/test/plugins/builtin/oras/test_oras.py index a51e9e30..decbcd14 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras.py +++ b/posit-bakery/test/plugins/builtin/oras/test_oras.py @@ -13,7 +13,6 @@ find_oras_bin, get_repository_from_ref, OrasCopy, - OrasManifestDelete, OrasManifestIndexCreate, OrasMergeWorkflow, OrasMergeWorkflowResult, @@ -188,34 +187,6 @@ def test_run_success(self): assert result.returncode == 0 -class TestOrasManifestDelete: - """Tests for the OrasManifestDelete command.""" - - def test_command_construction(self): - """Test that the command is constructed correctly.""" - cmd = OrasManifestDelete( - oras_bin="oras", - reference="ghcr.io/posit-dev/test/tmp:tag", - ) - - expected = ["oras", "manifest", "delete", "--force", "ghcr.io/posit-dev/test/tmp:tag"] - assert cmd.command == expected - - def test_run_success(self): - """Test successful delete execution.""" - cmd = OrasManifestDelete( - oras_bin="oras", - reference="ghcr.io/posit-dev/test/tmp:tag", - ) - - with patch("subprocess.run") as mock_run: - mock_run.return_value = subprocess.CompletedProcess(args=cmd.command, returncode=0, stdout=b"", stderr=b"") - result = cmd.run() - - mock_run.assert_called_once_with(cmd.command, capture_output=True) - assert result.returncode == 0 - - class TestOrasMergeWorkflow: """Tests for the OrasMergeWorkflow orchestrator.""" @@ -256,6 +227,7 @@ def mock_image_target(self): mock_tag4.__str__ = lambda self: "docker.io/posit/test-image:latest" mock_target.tags = StringableList([mock_tag1, mock_tag2, mock_tag3, mock_tag4]) + mock_target.temp_tag_name = "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" return mock_target @pytest.fixture @@ -295,8 +267,9 @@ def test_execute_success(self, basic_workflow): assert result.temp_index_ref is not None # Should have called: - # 1 create + 2 copy (grouped by destination) + 1 delete = 4 calls - assert mock_run.call_count == 4 + # 1 create + 2 copy (grouped by destination) = 3 calls. + # The temporary index is no longer deleted here; clean.yml handles it. + assert mock_run.call_count == 3 def test_execute_dry_run(self, basic_workflow): """Test dry run mode.""" @@ -307,6 +280,35 @@ def test_execute_dry_run(self, basic_workflow): assert result.success is True assert len(result.destinations) == 4 + def test_index_only_creates_index_and_skips_copy(self, basic_workflow): + """index_only runs only the create step (1 call), no copy-to-final.""" + with patch("subprocess.run") as mock_run: + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + result = basic_workflow.run(index_only=True) + + assert result.success is True + assert result.error is None + # Only the index-create call runs; the 2 copy calls are skipped. + assert mock_run.call_count == 1 + # The index is pushed to the stable temp tag, and no final destinations are reported. + assert result.temp_index_ref == "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" + assert result.destinations == [] + + def test_index_only_dry_run_runs_nothing(self, basic_workflow): + """dry_run takes precedence over index_only.""" + with patch("subprocess.run") as mock_run: + result = basic_workflow.run(dry_run=True, index_only=True) + + mock_run.assert_not_called() + assert result.success is True + assert result.temp_index_ref == "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" + + def test_index_only_without_temp_registry_raises(self, basic_workflow): + """index_only requires temp_registry; a None temp_tag_name is a clear error.""" + basic_workflow.image_target.temp_tag_name = None + with pytest.raises(ValueError, match="temp_registry"): + basic_workflow.run(index_only=True) + def test_execute_failure_on_create(self, basic_workflow): """Test workflow handles failure during index creation.""" with patch("subprocess.run") as mock_run: @@ -519,17 +521,6 @@ def test_copy_with_plain_http(self): expected = ["oras", "cp", "--plain-http", "localhost:5000/test:source", "localhost:5000/test:dest"] assert cmd.command == expected - def test_manifest_delete_with_plain_http(self): - """Test that --plain-http flag is included when plain_http=True.""" - cmd = OrasManifestDelete( - oras_bin="oras", - reference="localhost:5000/test:tag", - plain_http=True, - ) - - expected = ["oras", "manifest", "delete", "--force", "--plain-http", "localhost:5000/test:tag"] - assert cmd.command == expected - @pytest.mark.slow class TestOrasMergeWorkflowIntegration: diff --git a/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py b/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py index 87bb1c8f..60ef9e4e 100644 --- a/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py +++ b/posit-bakery/test/plugins/builtin/oras/test_oras_plugin.py @@ -44,6 +44,7 @@ def mock_target_with_sources(): mock_tag.suffix = "1.0.0" mock_tag.__str__ = lambda self: "ghcr.io/posit-dev/test-image:1.0.0" mock_target.tags = StringableList([mock_tag]) + mock_target.temp_tag_name = "ghcr.io/posit-dev/test-image/tmp:test-image-1-0-0" return mock_target @@ -141,6 +142,26 @@ def test_execute_dry_run(self, plugin, mock_target_with_sources): assert results[0].exit_code == 0 assert results[0].artifacts["workflow_result"].success is True + def test_execute_index_only(self, plugin, mock_target_with_sources): + with ( + patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), + patch("subprocess.run") as mock_run, + ): + mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout=b"", stderr=b"") + results = plugin.execute( + Path("/project"), + [mock_target_with_sources], + index_only=True, + ) + + assert len(results) == 1 + assert results[0].exit_code == 0 + wf = results[0].artifacts["workflow_result"] + assert wf.success is True + assert wf.destinations == [] + # Only the index-create subprocess call ran (no copy-to-final). + assert mock_run.call_count == 1 + def test_execute_mixed_targets(self, plugin, mock_target_with_sources, mock_target_without_sources): with ( patch("posit_bakery.plugins.builtin.oras.oras.find_oras_bin", return_value="oras"), @@ -191,7 +212,7 @@ def make_target(name, sort_key): call_order = [] - def fake_run(self_workflow, dry_run=False): + def fake_run(self_workflow, dry_run=False, index_only=False): call_order.append(self_workflow.image_target.image_name) return OrasMergeWorkflowResult(success=True, destinations=[])