diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 72c43b8..c9856f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -466,8 +466,8 @@ jobs: releases/**/*.exe.sha256 if-no-files-found: error - publish-pypi: - name: Publish to PyPI + validate-built-artifacts: + name: Validate built release artifacts runs-on: ubuntu-latest needs: - build-python @@ -487,93 +487,6 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Report recovery PyPI policy - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_pypi != 'true' }} - run: | - echo "INFO: PyPI publish disabled for recovery run" - - - name: Verify PyPI namespace ownership - if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} - run: | - set -e - name=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["name"])') - if [ "$name" != "ecli-editor" ]; then - echo "::error::pyproject.toml [project].name is '$name', expected 'ecli-editor'." - echo "::error::Reserved PyPI namespace is 'ecli-editor'. Either update pyproject.toml or reserve the new name first." - exit 1 - fi - lookup=$(python3 -m pip index versions ecli-editor 2>&1 || true) - if ! echo "$lookup" | grep -q "Available versions"; then - echo "::error::PyPI project 'ecli-editor' lookup failed. Network issue or namespace deletion?" - echo "$lookup" - exit 1 - fi - echo "::notice::PyPI namespace ecli-editor confirmed. Current versions:" - echo "$lookup" - - - name: Download Python distributions - if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} - uses: actions/download-artifact@v4 - with: - name: python-dist - path: dist - - - name: Normalize and verify PyPI distributions - if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} - run: | - set -euo pipefail - echo "Downloaded Python distribution artifact layout:" - find dist -maxdepth 3 -type f -print | sort - mkdir -p dist-flat - find dist -maxdepth 3 -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist-flat/ \; - rm -rf dist - mkdir -p dist - shopt -s nullglob - dists=(dist-flat/*) - if (( ${#dists[@]} == 0 )); then - echo "::error::No wheel or sdist files found in downloaded python-dist artifact." - exit 1 - fi - mv "${dists[@]}" dist/ - echo "Flattened PyPI distribution layout:" - find dist -maxdepth 1 -type f -print | sort - python3 -m pip install --upgrade twine - python3 -m twine check dist/* - - - name: Publish to PyPI - if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} - # Pinned from pypa/gh-action-pypi-publish release/v1 on 2026-05-10. - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b - with: - password: ${{ secrets.PYPI_API_TOKEN }} - attestations: false - - publish-github-release: - name: Publish GitHub release - runs-on: ubuntu-latest - needs: - - build-python - - build-linux - - build-freebsd - - build-macos - - build-windows - if: | - always() && - (github.event_name == 'push' || github.event.inputs.publish_github_release != 'false') && - needs.build-python.result == 'success' && - needs.build-linux.result == 'success' && - needs.build-freebsd.result == 'success' && - needs.build-macos.result == 'success' && - needs.build-windows.result == 'success' - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} @@ -586,20 +499,27 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" echo "tag=v${version}" >> "$GITHUB_OUTPUT" + - name: Install final artifact validation tooling + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y --no-install-recommends cpio rpm + python3 -m pip install --upgrade twine + - name: Download all artifacts uses: actions/download-artifact@v4 with: path: release-inputs merge-multiple: true - - name: Assemble and verify exact 21 GitHub Release assets + - name: Assemble release assets and adjacent checksum evidence run: | set -euo pipefail shopt -s globstar nullglob version="${{ steps.release_meta.outputs.version }}" release_dir="releases/${version}" rm -rf "$release_dir" - mkdir -p "$release_dir/.checksums" + mkdir -p "$release_dir" copy_one() { local pattern="$1" @@ -685,15 +605,166 @@ jobs: for asset in "$release_dir"/*; do test -f "$asset" || continue - (cd "$release_dir" && sha256sum "$(basename "$asset")" > ".checksums/$(basename "$asset").sha256") + (cd "$release_dir" && sha256sum "$(basename "$asset")" > "$(basename "$asset").sha256") done + - name: Validate final built artifacts + run: | + set -euo pipefail + make validate-built-artifacts MACOS_ASSERT_MODE=structural + + - name: Stage checksum evidence and verify exact 21 GitHub Release assets + run: | + set -euo pipefail + shopt -s nullglob + version="${{ steps.release_meta.outputs.version }}" + release_dir="releases/${version}" + mkdir -p "$release_dir/.checksums" + sidecars=( "$release_dir"/*.sha256 ) + if (( ${#sidecars[@]} == 0 )); then + echo "::error::No checksum sidecars were generated for $release_dir" + exit 3 + fi + for sidecar in "${sidecars[@]}"; do + mv "$sidecar" "$release_dir/.checksums/$(basename "$sidecar")" + done python3 scripts/verify_release_assets.py \ --version "$version" \ --f4-evidence-dir build/evidence/f4_linter_provisioning find "$release_dir" -maxdepth 1 -type f -print | sort find "$release_dir/.checksums" -maxdepth 1 -type f -print | sort + - name: Upload validated release directory + uses: actions/upload-artifact@v4 + with: + name: validated-release-assets + path: | + releases/${{ steps.release_meta.outputs.version }}/ecli_* + releases/${{ steps.release_meta.outputs.version }}/.checksums/*.sha256 + include-hidden-files: true + if-no-files-found: error + + publish-pypi: + name: Publish to PyPI + runs-on: ubuntu-latest + needs: + - validate-built-artifacts + if: | + always() && + (github.event_name == 'push' || github.event.inputs.publish_pypi == 'true') && + needs.validate-built-artifacts.result == 'success' + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Report recovery PyPI policy + if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.publish_pypi != 'true' }} + run: | + echo "INFO: PyPI publish disabled for recovery run" + + - name: Verify PyPI namespace ownership + if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} + run: | + set -e + name=$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["name"])') + if [ "$name" != "ecli-editor" ]; then + echo "::error::pyproject.toml [project].name is '$name', expected 'ecli-editor'." + echo "::error::Reserved PyPI namespace is 'ecli-editor'. Either update pyproject.toml or reserve the new name first." + exit 1 + fi + lookup=$(python3 -m pip index versions ecli-editor 2>&1 || true) + if ! echo "$lookup" | grep -q "Available versions"; then + echo "::error::PyPI project 'ecli-editor' lookup failed. Network issue or namespace deletion?" + echo "$lookup" + exit 1 + fi + echo "::notice::PyPI namespace ecli-editor confirmed. Current versions:" + echo "$lookup" + + - name: Download Python distributions + if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} + uses: actions/download-artifact@v4 + with: + name: python-dist + path: dist + + - name: Normalize and verify PyPI distributions + if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} + run: | + set -euo pipefail + echo "Downloaded Python distribution artifact layout:" + find dist -maxdepth 3 -type f -print | sort + mkdir -p dist-flat + find dist -maxdepth 3 -type f \( -name '*.whl' -o -name '*.tar.gz' \) -exec cp {} dist-flat/ \; + rm -rf dist + mkdir -p dist + shopt -s nullglob + dists=(dist-flat/*) + if (( ${#dists[@]} == 0 )); then + echo "::error::No wheel or sdist files found in downloaded python-dist artifact." + exit 1 + fi + mv "${dists[@]}" dist/ + echo "Flattened PyPI distribution layout:" + find dist -maxdepth 1 -type f -print | sort + python3 -m pip install --upgrade twine + python3 -m twine check dist/* + + - name: Publish to PyPI + if: ${{ github.event_name == 'push' || github.event.inputs.publish_pypi == 'true' }} + # Pinned from pypa/gh-action-pypi-publish release/v1 on 2026-05-10. + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b + with: + password: ${{ secrets.PYPI_API_TOKEN }} + attestations: false + + publish-github-release: + name: Publish GitHub release + runs-on: ubuntu-latest + needs: + - validate-built-artifacts + if: | + always() && + (github.event_name == 'push' || github.event.inputs.publish_github_release != 'false') && + needs.validate-built-artifacts.result == 'success' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Read release metadata + id: release_meta + run: | + set -euo pipefail + version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml","rb"))["project"]["version"])')" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "tag=v${version}" >> "$GITHUB_OUTPUT" + + - name: Download validated release directory + uses: actions/download-artifact@v4 + with: + name: validated-release-assets + path: releases/${{ steps.release_meta.outputs.version }} + + - name: Verify exact 21 GitHub Release assets before upload + run: | + set -euo pipefail + version="${{ steps.release_meta.outputs.version }}" + release_dir="releases/${version}" + python3 scripts/verify_release_assets.py \ + --version "$version" + find "$release_dir" -maxdepth 1 -type f -print | sort + find "$release_dir/.checksums" -maxdepth 1 -type f -print | sort + - name: Create or update GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/Makefile b/Makefile index a388665..c1a1602 100755 --- a/Makefile +++ b/Makefile @@ -1272,7 +1272,6 @@ validate-built-artifacts: $(call validate_artifact_if_requested,$(FREEBSD_PKG_FILE),validate-freebsd-contract,FreeBSD) $(call validate_artifact_if_requested,$(MACOS_PKG_FILE),validate-macos-contract,macOS) $(call validate_artifact_if_requested,$(WIN_PKG_FILE),validate-windows-contract,Windows) - $(call validate_release_assets_if_present) @echo "--> OK: built artifact validation completed" diff --git a/docs/release/release-checklist.md b/docs/release/release-checklist.md index 56679a1..430de3c 100644 --- a/docs/release/release-checklist.md +++ b/docs/release/release-checklist.md @@ -38,12 +38,18 @@ See the LICENSE file in the project root for full license text. - [ ] `make help`, `make help-full`, `make list-targets`, `make doctor`, and `make sysinfo` match current package surfaces and canonical Python scripts. -- [ ] `make validate-gate2` passes before any publish step. +- [ ] `make validate-gate2` passes as source and structural contract + validation; it does not inspect ignored historical release artifacts. - [ ] `make validate-official-evidence-drift` passes; this invokes `scripts/f4_linter_linux_provisioning.py --check-official-evidence-drift` and blocks release readiness if the Linux official distro evidence registry drifts from generated evidence. -- [ ] `make validate-release-assets` passes against `releases//`. +- [ ] The canonical `Release` workflow `validate-built-artifacts` job downloads + all build outputs, assembles `releases//`, generates adjacent + `.sha256` sidecars, runs `make validate-built-artifacts`, and is required + by both GitHub Release and PyPI publication jobs. +- [ ] `make validate-release-assets` passes against `releases//` after + checksum sidecars are staged under `.checksums/`. - [ ] Required packaging scripts exist and are executable. - [ ] Active shell wrappers under `scripts/` are absent; Python entrypoints under `scripts/` are canonical. Windows PowerShell packaging diff --git a/docs/release/release-process.md b/docs/release/release-process.md index c3c8800..5e6a9d0 100644 --- a/docs/release/release-process.md +++ b/docs/release/release-process.md @@ -22,17 +22,21 @@ See the LICENSE file in the project root for full license text. ## Process Stages 1. Build platform artifacts -2. Validate artifact contract and checksums -3. Publish package artifacts -4. Publish Python distribution (if enabled in workflow) -5. Publish release notes and release assets +2. Assemble current-version release artifacts with adjacent checksum evidence +3. Run the built-artifact gate +4. Stage checksum sidecars under `.checksums/` and verify the exact 21-asset set +5. Publish package artifacts, release notes, and release assets ## Required Controls - Contract validation must happen before publish. -- `make validate-gate2` is the required pre-publish validation gate. +- `make validate-gate2` is the source and structural contract gate; it must not + inspect ignored historical release directories. +- `make validate-built-artifacts` is the required built-artifact gate after the + current release directory is assembled with adjacent checksum sidecars and + before GitHub Release or PyPI publication can run. - `make validate-release-assets` is the exact 21 ECLI-owned GitHub Release asset - gate. + gate after checksum sidecars have been staged under `.checksums/`. - Missing required artifacts must block release. - Every official ECLI release uploads exactly 21 ECLI-owned physical GitHub Release assets, one per canonical matrix entry. Release publication is @@ -104,6 +108,13 @@ it delegates to `validate-pypi-contract`, which requires `twine` for strict PyPI wheel/sdist metadata validation. `twine` is declared only in release/development tooling dependencies, not in ECLI runtime dependencies. +The canonical `Release` workflow runs `make validate-built-artifacts` in the +`validate-built-artifacts` job after downloading all build outputs, assembling +`releases//`, and generating adjacent `.sha256` sidecars. The workflow +then moves those sidecars under `releases//.checksums/`, runs +`scripts/verify_release_assets.py` for the final exact-21 contract, and only then +allows GitHub Release or PyPI publication jobs to run. + ## PyPI Namespace Pre-Reservation The Python distribution name is `ecli-editor`; the import package and console diff --git a/tests/packaging/test_packaging_workflows_contract.py b/tests/packaging/test_packaging_workflows_contract.py index d3b865d..529e234 100644 --- a/tests/packaging/test_packaging_workflows_contract.py +++ b/tests/packaging/test_packaging_workflows_contract.py @@ -13,6 +13,7 @@ from __future__ import annotations +import re from pathlib import Path from conftest import PathAssertion, RepoReader, TokenAssertion @@ -108,6 +109,7 @@ "Build Python distributions", "Build Linux packages", "Build Windows artifacts", + "validate-built-artifacts", "verify_release_assets.py", ], "surface_docs": [ @@ -152,6 +154,76 @@ } +_JOB_HEADER_RE = re.compile(r"^ [A-Za-z0-9_-]+:\n", re.MULTILINE) + + +def _job_block(workflow: str, job_name: str) -> str: + match = re.search(rf"^ {re.escape(job_name)}:\n", workflow, re.MULTILINE) + assert match is not None, f"job not found: {job_name}" + next_job = _JOB_HEADER_RE.search(workflow, match.end()) + return ( + workflow[match.start() :] + if next_job is None + else workflow[match.start() : next_job.start()] + ) + + +def _step_block(job: str, step_name: str) -> str: + start = job.index(f"- name: {step_name}") + next_step = job.find("\n - name:", start + 1) + return job[start:] if next_step == -1 else job[start:next_step] + + +def _job_if_condition(job: str) -> tuple[str, ...]: + match = re.search(r"^ if: \|\n(?P(?: .+\n)+)", job, re.MULTILINE) + assert match is not None + return tuple(line.removeprefix(" ") for line in match["body"].splitlines()) + + +_VALIDATE_BUILT_ARTIFACTS_IF = ( + "always() &&", + "needs.build-python.result == 'success' &&", + "needs.build-linux.result == 'success' &&", + "needs.build-freebsd.result == 'success' &&", + "needs.build-macos.result == 'success' &&", + "needs.build-windows.result == 'success'", +) + +_PUBLISH_PYPI_IF = ( + "always() &&", + "(github.event_name == 'push' || github.event.inputs.publish_pypi == 'true') &&", + "needs.validate-built-artifacts.result == 'success'", +) + +_PUBLISH_GITHUB_RELEASE_IF = ( + "always() &&", + "(github.event_name == 'push' || github.event.inputs.publish_github_release != 'false') &&", + "needs.validate-built-artifacts.result == 'success'", +) + + +def _pypi_publication_allowed( + *, + event_name: str, + publish_pypi: str, + validation_result: str, +) -> bool: + return validation_result == "success" and ( + event_name == "push" or publish_pypi == "true" + ) + + +def _github_release_publication_allowed( + *, + event_name: str, + publish_github_release: str, + validation_result: str, +) -> bool: + return validation_result == "success" and ( + event_name == "push" or publish_github_release != "false" + ) + + def test_declared_workflow_files_exist_and_are_non_empty( assert_paths_non_empty: PathAssertion, ) -> None: @@ -327,11 +399,226 @@ def test_release_workflow_preserves_exact_21_asset_contract( # 6: the exact-21 release contract is unchanged -- the workflow still # assembles/verifies exactly 21 assets through the canonical verifier. - assert "Assemble and verify exact 21 GitHub Release assets" in release + assert "Stage checksum evidence and verify exact 21 GitHub Release assets" in ( + release + ) + assert "Verify exact 21 GitHub Release assets before upload" in release assert "exactly 21 physical GitHub Release" in release assert "verify_release_assets.py" in release +def test_release_workflow_runs_built_artifact_gate_after_assembly( + read_repo_text: RepoReader, +) -> None: + release = read_repo_text(".github/workflows/release.yml") + validation_job = _job_block(release, "validate-built-artifacts") + + download = validation_job.index("- name: Download all artifacts") + assembly = validation_job.index( + "- name: Assemble release assets and adjacent checksum evidence" + ) + gate = validation_job.index("make validate-built-artifacts") + stage = validation_job.index( + "- name: Stage checksum evidence and verify exact 21 GitHub Release assets" + ) + + assert download < assembly < gate < stage + assert 'sha256sum "$(basename "$asset")" > "$(basename "$asset").sha256"' in ( + validation_job + ) + assert 'mv "$sidecar" "$release_dir/.checksums/$(basename "$sidecar")"' in ( + validation_job + ) + + +def test_release_workflow_orders_publication_behind_built_artifact_gate( + read_repo_text: RepoReader, +) -> None: + release = read_repo_text(".github/workflows/release.yml") + pypi_job = _job_block(release, "publish-pypi") + github_job = _job_block(release, "publish-github-release") + + assert "needs:\n - validate-built-artifacts" in pypi_job + assert "needs.validate-built-artifacts.result == 'success'" in pypi_job + assert "needs:\n - validate-built-artifacts" in github_job + assert "needs.validate-built-artifacts.result == 'success'" in github_job + + verify = github_job.index( + "- name: Verify exact 21 GitHub Release assets before upload" + ) + release_action = github_job.index("uses: softprops/action-gh-release@v2") + assert verify < release_action + + +def test_release_workflow_publication_job_conditions_are_explicit( + read_repo_text: RepoReader, +) -> None: + release = read_repo_text(".github/workflows/release.yml") + + assert ( + _job_if_condition(_job_block(release, "validate-built-artifacts")) + == _VALIDATE_BUILT_ARTIFACTS_IF + ) + assert _job_if_condition(_job_block(release, "publish-pypi")) == _PUBLISH_PYPI_IF + assert ( + _job_if_condition(_job_block(release, "publish-github-release")) + == _PUBLISH_GITHUB_RELEASE_IF + ) + + +def test_release_workflow_publication_policy_matrix( + read_repo_text: RepoReader, +) -> None: + release = read_repo_text(".github/workflows/release.yml") + assert _job_if_condition(_job_block(release, "publish-pypi")) == _PUBLISH_PYPI_IF + assert ( + _job_if_condition(_job_block(release, "publish-github-release")) + == _PUBLISH_GITHUB_RELEASE_IF + ) + + scenarios = ( + ( + "tag push", + "push", + "false", + "false", + True, + True, + ), + ( + "workflow_dispatch publish_pypi=true", + "workflow_dispatch", + "true", + "false", + True, + False, + ), + ( + "workflow_dispatch PyPI disabled, GitHub release enabled", + "workflow_dispatch", + "false", + "true", + False, + True, + ), + ( + "workflow_dispatch both publication flags false", + "workflow_dispatch", + "false", + "false", + False, + False, + ), + ( + "workflow_dispatch both publication flags true", + "workflow_dispatch", + "true", + "true", + True, + True, + ), + ) + + for ( + case_name, + event_name, + publish_pypi, + publish_github_release, + expect_pypi, + expect_github_release, + ) in scenarios: + assert ( + _pypi_publication_allowed( + event_name=event_name, + publish_pypi=publish_pypi, + validation_result="success", + ) + is expect_pypi + ), case_name + assert ( + _github_release_publication_allowed( + event_name=event_name, + publish_github_release=publish_github_release, + validation_result="success", + ) + is expect_github_release + ), case_name + + +def test_release_workflow_publication_jobs_require_validation_success( + read_repo_text: RepoReader, +) -> None: + release = read_repo_text(".github/workflows/release.yml") + pypi_job = _job_block(release, "publish-pypi") + github_job = _job_block(release, "publish-github-release") + + assert "needs.validate-built-artifacts.result == 'success'" in pypi_job + assert "needs.validate-built-artifacts.result == 'success'" in github_job + assert not _pypi_publication_allowed( + event_name="push", + publish_pypi="true", + validation_result="failure", + ) + assert not _github_release_publication_allowed( + event_name="push", + publish_github_release="true", + validation_result="failure", + ) + + +def test_built_artifact_gate_failure_is_not_masked( + read_repo_text: RepoReader, +) -> None: + release = read_repo_text(".github/workflows/release.yml") + validation_job = _job_block(release, "validate-built-artifacts") + gate_step = _step_block(validation_job, "Validate final built artifacts") + + assert "make validate-built-artifacts" in gate_step + assert "set -euo pipefail" in gate_step + assert "|| true" not in gate_step + assert "continue-on-error" not in gate_step + + +def test_source_only_ci_does_not_run_final_built_artifact_gate( + read_repo_text: RepoReader, +) -> None: + for workflow_name in ( + "ci.yml", + "pypi-validate.yml", + "windows-validate.yml", + "macos-validate.yml", + ): + workflow = read_repo_text(f".github/workflows/{workflow_name}") + assert "validate-built-artifacts" not in workflow + + +def test_release_workflow_preserves_release_upload_asset_glob( + read_repo_text: RepoReader, +) -> None: + release = read_repo_text(".github/workflows/release.yml") + github_job = _job_block(release, "publish-github-release") + release_step = _step_block(github_job, "Create or update GitHub Release") + + assert "files: releases/${{ steps.release_meta.outputs.version }}/ecli_*" in ( + release_step + ) + assert "fail_on_unmatched_files: true" in release_step + assert ".sha256" not in release_step + assert "gh release upload" not in release + assert ( + "path: |\n releases/${{ steps.release_meta.outputs.version }}/ecli_*" + in (release) + ) + assert "files: releases/${{ steps.release_meta.outputs.version }}/**" not in ( + release + ) + assert ( + "path: |\n releases/${{ steps.release_meta.outputs.version }}/**" + not in (release) + ) + assert "releases/0.2.3" not in release + + def test_makefile_docker_package_targets_reset_release_ownership( read_repo_text: RepoReader, ) -> None: diff --git a/tests/packaging/test_release_asset_count_gate.py b/tests/packaging/test_release_asset_count_gate.py index cd2132a..b630d87 100644 --- a/tests/packaging/test_release_asset_count_gate.py +++ b/tests/packaging/test_release_asset_count_gate.py @@ -425,7 +425,9 @@ def test_validate_gate2_separates_source_and_built_artifact_checks( assert "$(call validate_pypi_artifacts_if_requested)" in built_artifacts assert "$(call validate_artifact_if_requested" in built_artifacts - assert "$(call validate_release_assets_if_present)" in built_artifacts + assert "$(call validate_release_assets_if_present)" not in built_artifacts + assert "validate-release-assets:" in makefile + assert 'if [ -d "$(RELEASE_DIR)" ]' in makefile def test_validate_gate2_requires_complete_artifact_sidecar_pairs(