diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 4f6b705..0602697 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -77,3 +77,23 @@ jobs: --workdir /workspace \ docker.io/rhysd/actionlint@sha256:9d36088643581e728c969f35141f88139fec77280b2be23c1f66f8e40e1025e7 \ -color + + quality-contract: + name: Quality contract + if: ${{ always() }} + needs: [quality] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Require the quality lane to succeed + env: + QUALITY_RESULT: ${{ needs.quality.result }} + run: | + set -euo pipefail + if [[ "$QUALITY_RESULT" != success ]]; then + printf 'Quality contract is blocked by a non-success quality lane: %s\n' \ + "$QUALITY_RESULT" >&2 + exit 1 + fi + printf 'Quality contract passed: the quality lane succeeded.\n' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 24b8c4a..788ab5f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -230,3 +230,33 @@ jobs: framework_commit="$(git rev-parse --verify 'HEAD^{commit}')" .downstream/base-bash-libs-demo/tests/candidate-smoke.sh \ "$GITHUB_WORKSPACE" "$(< VERSION)" "$framework_commit" + + product-validation: + name: Product validation + if: ${{ always() }} + needs: [validate, bash-42-logging, representative-bash, release-gates, downstream-demo] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: Require every product lane to succeed + env: + VALIDATE_RESULT: ${{ needs.validate.result }} + BASH_42_RESULT: ${{ needs.bash-42-logging.result }} + REPRESENTATIVE_BASH_RESULT: ${{ needs.representative-bash.result }} + RELEASE_GATES_RESULT: ${{ needs.release-gates.result }} + DOWNSTREAM_DEMO_RESULT: ${{ needs.downstream-demo.result }} + run: | + set -euo pipefail + failures=() + [[ "$VALIDATE_RESULT" == success ]] || failures+=("Validate") + [[ "$BASH_42_RESULT" == success ]] || failures+=("Compatibility smoke (Bash 4.2.53)") + [[ "$REPRESENTATIVE_BASH_RESULT" == success ]] || failures+=("Compatibility (Bash matrix)") + [[ "$RELEASE_GATES_RESULT" == success ]] || failures+=("Release gates (matrix and provenance)") + [[ "$DOWNSTREAM_DEMO_RESULT" == success ]] || failures+=("Downstream demo (Beacon)") + if (( ${#failures[@]} > 0 )); then + printf 'Product validation is blocked by non-success lanes: %s\n' \ + "${failures[*]}" >&2 + exit 1 + fi + printf 'Product validation passed: all required product lanes succeeded.\n' diff --git a/docs/ci-policy.md b/docs/ci-policy.md index 7a65b2e..7f440e1 100644 --- a/docs/ci-policy.md +++ b/docs/ci-policy.md @@ -6,6 +6,25 @@ out of the containerized compatibility and lint checks. Actions and container images are pinned to full immutable commit or digest references; changing one requires a reviewable dependency update. +Each workflow ends with a stable, fail-closed aggregate context: + +- `Product validation` depends on Linux and macOS repository validation, the + Bash 4.2.53 minimum-runtime smoke, the representative Bash compatibility + matrix, release/provenance gates, and the pinned Beacon downstream smoke. +- `Quality contract` depends on the ShellCheck, repository-contract, shfmt, and + actionlint quality lane. + +Both aggregates run with `always()` so a cancelled, failed, or unexpectedly +skipped dependency cannot leave a green required context. They explicitly +require every dependency result to equal `success`. The individual lanes remain +visible for diagnosis, while the aggregate contexts are the merge-policy +surface. + +These aggregates run in the same `pull_request` workflows they protect; they +are not a trusted `pull_request_target` boundary. That is an intentional +solo-maintainer trade-off for this repository, and changes to the aggregate +logic still require review through the repository's existing workflow. + The workflows emitted by `base-bash init` follow the same policy: every third-party action is pinned to a full commit SHA and carries a human-readable release comment. Generated consumer workflows can therefore be reviewed and @@ -16,7 +35,7 @@ latest commit on `main`). This prevents new formatting debt while allowing the existing v1-to-v2 codebase to be cleaned incrementally; touching a legacy file puts its complete contents under the formatter gate. -## Default-branch baseline +## Default-branch baseline (current) `base-bash-libs` follows the same modest default-branch baseline as Base: @@ -26,12 +45,41 @@ puts its complete contents under the formatter gate. `base/issue-branch-policy` status, and prevents deletion and non-fast-forward updates; - administrators remain subject to branch protection; and -- no approval count or individual Tests/Quality job is a default merge - requirement. +- no approval count is a default merge requirement. + +## Planned required aggregate contexts + +After the aggregate jobs land on `main`, the effective ruleset should require +these exact GitHub Actions contexts in addition to `base/issue-branch-policy`: + +| Context | Actions integration | Coverage | +| --- | ---: | --- | +| `Product validation` | `15368` | Supported-platform, minimum-runtime, compatibility, release-contract, and Beacon evidence | +| `Quality contract` | `15368` | ShellCheck, repository quality, shfmt, and actionlint evidence | + +The repository owner must add those contexts through the normal reviewed +ruleset/configuration workflow, then read back both the effective ruleset and +classic branch protection. The readback must confirm the exact context names, +integration ID `15368`, strictness, review/thread settings, and any existing +stronger controls. `base/issue-branch-policy` remains required; project metadata +intake remains outside these product gates. Until that administrative readback +is complete, the aggregate checks are present and fail closed but are not yet +merge-blocking. + +The repeatable readback commands are: + +```bash +gh api repos/basefoundry/base-bash-libs/rules/branches/main +gh api repos/basefoundry/base-bash-libs/branches/main/protection +``` + +Inspect the returned rules and required-status contexts rather than relying on +the workflow files alone. This is a merge-policy choice, not a validation waiver. The `Tests` and -`Quality` workflows still run on pull requests and `main`, and they remain -release gates. Run the complete local validation and release readiness checks +`Quality` workflows still run on pull requests and `main`, and their aggregate +contexts are the merge-blocking release gates once the ruleset readback is +complete. Run the complete local validation and release readiness checks before publishing a release, even when a pull request can merge after the issue-branch policy succeeds. diff --git a/tests/ci-policy-contract.sh b/tests/ci-policy-contract.sh new file mode 100755 index 0000000..f138fbf --- /dev/null +++ b/tests/ci-policy-contract.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)" || exit 1 +tests_workflow="$repo_root/.github/workflows/tests.yml" +quality_workflow="$repo_root/.github/workflows/quality.yml" + +contract_fail() { + printf 'CI policy contract failed: %s\n' "$*" >&2 + exit 1 +} + +for file in "$tests_workflow" "$quality_workflow"; do + [[ -f "$file" ]] || contract_fail "missing workflow: $file" +done + +grep -F ' product-validation:' "$tests_workflow" > /dev/null || + contract_fail 'product validation aggregate job is missing' +grep -F ' name: Product validation' "$tests_workflow" > /dev/null || + contract_fail 'product validation context name is unstable or missing' +grep -F ' if: ${{ always() }}' "$tests_workflow" > /dev/null || + contract_fail 'product validation must run after skipped or failed dependencies' +grep -F ' needs: [validate, bash-42-logging, representative-bash, release-gates, downstream-demo]' \ + "$tests_workflow" > /dev/null || + contract_fail 'product validation must cover every product lane' +for result_name in VALIDATE_RESULT BASH_42_RESULT REPRESENTATIVE_BASH_RESULT RELEASE_GATES_RESULT DOWNSTREAM_DEMO_RESULT; do + grep -F " $result_name:" "$tests_workflow" > /dev/null || + contract_fail "product result is not wired: $result_name" + grep -F "[[ \"\$$result_name\" == success ]]" "$tests_workflow" > /dev/null || + contract_fail "product result is not required to succeed: $result_name" +done + +product_needs_line="$( + awk ' + /^ product-validation:/ { in_product = 1; next } + in_product && /^ [A-Za-z0-9_-]+:/ { exit } + in_product && /needs:/ { print; exit } + ' "$tests_workflow" +)" +[[ -n "$product_needs_line" ]] || + contract_fail 'product validation needs list is missing' +while IFS= read -r job_id; do + [[ "$job_id" == product-validation ]] && continue + awk -v expected_job="$job_id" ' + { + for (i = 1; i <= NF; i++) { + token = $i + gsub(/[\[\],]/, "", token) + if (token == expected_job) { + found = 1 + } + } + } + END { exit !found } + ' <<< "$product_needs_line" || + contract_fail "product validation does not cover job: $job_id" +done < <( + awk ' + /^jobs:/ { in_jobs = 1; next } + in_jobs && /^ [A-Za-z0-9_-]+:/ { + job_id = $1 + sub(/:$/, "", job_id) + print job_id + } + ' "$tests_workflow" +) + +grep -F ' quality-contract:' "$quality_workflow" > /dev/null || + contract_fail 'quality aggregate job is missing' +grep -F ' name: Quality contract' "$quality_workflow" > /dev/null || + contract_fail 'quality context name is unstable or missing' +grep -F ' if: ${{ always() }}' "$quality_workflow" > /dev/null || + contract_fail 'quality aggregate must run after a skipped or failed dependency' +grep -F ' needs: [quality]' "$quality_workflow" > /dev/null || + contract_fail 'quality aggregate must depend on the quality lane' +grep -F ' QUALITY_RESULT: ${{ needs.quality.result }}' "$quality_workflow" > /dev/null || + contract_fail 'quality result is not wired into the aggregate' +grep -F 'if [[ "$QUALITY_RESULT" != success ]]' "$quality_workflow" > /dev/null || + contract_fail 'quality aggregate must require success, not merely completion' + +printf 'CI policy contract passed: product and quality aggregates are explicit and fail closed.\n' diff --git a/tests/lint-warnings.sh b/tests/lint-warnings.sh index d5283e6..f27c231 100755 --- a/tests/lint-warnings.sh +++ b/tests/lint-warnings.sh @@ -37,6 +37,7 @@ lint_files=( tests/reference-release-contract.sh tests/concurrency-contract.sh tests/quality-contract.sh + tests/ci-policy-contract.sh tests/shfmt-contract.sh examples/std-usage.sh examples/cookbook-cleanup-temp.sh diff --git a/tests/validate.sh b/tests/validate.sh index f0d5669..6d101d3 100755 --- a/tests/validate.sh +++ b/tests/validate.sh @@ -63,6 +63,7 @@ required_files=( tests/integration-release-contract.sh tests/concurrency-contract.sh tests/quality-contract.sh + tests/ci-policy-contract.sh tests/shfmt-contract.sh tests/changelog-guard.sh tests/release-invariants.sh @@ -145,6 +146,7 @@ check_no_strict_mode() { tests/reference-release-contract.sh tests/concurrency-contract.sh tests/quality-contract.sh + tests/ci-policy-contract.sh tests/shfmt-contract.sh tests/changelog-guard.sh tests/release-invariants.sh @@ -432,6 +434,7 @@ run_stage "ShellCheck error profile" shellcheck --severity=error \ tests/reference-release-contract.sh \ tests/concurrency-contract.sh \ tests/quality-contract.sh \ + tests/ci-policy-contract.sh \ tests/shfmt-contract.sh \ tests/release-invariants.sh \ tests/release-invariants.bats \ @@ -506,6 +509,7 @@ run_stage "integration release contract" tests/integration-release-contract.sh | run_stage "reference release contract" tests/reference-release-contract.sh || exit $? run_stage "concurrency contract" tests/concurrency-contract.sh || exit $? run_stage "quality workflow contract" tests/quality-contract.sh || exit $? +run_stage "CI policy contract" tests/ci-policy-contract.sh || exit $? run_stage "support matrix" tests/compatibility-matrix.sh || exit $? run_stage "release invariants" tests/release-invariants.sh || exit $? run_stage "examples/std-usage.sh" examples/std-usage.sh > /dev/null || exit $?