diff --git a/.github/workflows/ci-post.yml b/.github/workflows/ci-post.yml new file mode 100644 index 0000000..925dd1e --- /dev/null +++ b/.github/workflows/ci-post.yml @@ -0,0 +1,450 @@ +name: CI + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + actions: read + +# Keep this workflow name for the existing release completion listener. +# Every post-merge run completes; only main may request workload runners. +concurrency: + group: ci-post-${{ github.run_id }} + cancel-in-progress: false + +jobs: + authz: + name: authz + # Standard GitHub-hosted label (owner #1050); never a larger-runner label. + runs-on: ubuntu-24.04 + permissions: {} + # No timeout-minutes: the owner's no-time-limits rule (check-no-time-limits refuses new ones). + steps: + - name: Only our own pull requests and our own events run CI + env: + EVENT: ${{ github.event_name }} + REPO: ${{ github.repository }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_IS_FORK: ${{ github.event.pull_request.head.repo.fork }} + ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + WR_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} + WR_ACTOR: ${{ github.event.workflow_run.triggering_actor.login }} + GH_TOKEN: ${{ github.token }} # read-only use: one permission lookup for workflow_run + # Bot logins whose same-repository pull requests count as ours: dependabot[bot] (CI-DEPBOT A) + # and the org's own GitHub App bot logins by name (space-separated), nothing else. + ALLOWED_BOTS: "dependabot[bot] pineforge-app[bot]" + run: | + set -eu + fail() { echo "::error title=authz::$1"; echo "authz: REFUSED: $1" >> "$GITHUB_STEP_SUMMARY"; exit 1; } + case "$EVENT" in + push|workflow_dispatch|schedule|merge_group|release|workflow_call|repository_dispatch) + # Only someone with write access to this repository (or our App's token) can cause these + # events; none of them runs pull-request code. + ;; + workflow_run) + # LB-437: bind identity to the triggering run and its re-runner. + # These two App bots are fixed canon policy; ALLOWED_BOTS is PR-only. + [ -n "${WR_HEAD_REPO:-}" ] || fail "workflow_run head repository is missing" + [ -n "${WR_ACTOR:-}" ] || fail "workflow_run triggering actor is missing" + [ "$WR_HEAD_REPO" = "$REPO" ] || fail "workflow_run head repository does not match this repository" + case "$WR_ACTOR" in + "pineforge-app[bot]"|"pineforge-ci-dispatcher[bot]") ;; + *"[bot]") fail "workflow_run bot is not on the canon allowlist" ;; + *) + perm=$(gh api "repos/$REPO/collaborators/$WR_ACTOR/permission" --jq .permission 2>/dev/null) || fail "workflow_run permission lookup failed" + case "$perm" in admin|maintain|write) ;; *) fail "workflow_run triggering actor does not have write permission" ;; esac ;; + esac ;; + pull_request) + [ "$HEAD_REPO" = "$REPO" ] || fail "pull request from another repository (${HEAD_REPO:-unknown})" + [ "$HEAD_IS_FORK" = "false" ] || fail "pull request from a fork" + case "$ASSOCIATION" in + OWNER|MEMBER|COLLABORATOR) ;; + *) + case " $ALLOWED_BOTS " in + *" $PR_AUTHOR "*) ;; + *) + # A member whose org membership is private shows as CONTRIBUTOR: ask GitHub for the + # author's permission on this repository; anything but admin/maintain/write (or a + # failed call) refuses. + perm=$(gh api "repos/$REPO/collaborators/$PR_AUTHOR/permission" --jq .permission 2>/dev/null || echo none) + case "$perm" in admin|maintain|write) ;; *) fail "author ${PR_AUTHOR:-unknown} (${ASSOCIATION:-NONE}) has permission '$perm', not write, and is not an allowlisted bot" ;; esac ;; + esac ;; + esac ;; + *) fail "event $EVENT does not run CI (pull_request_target and other events are not allowed)" ;; + esac + echo "authz: allowed ($EVENT${PR_AUTHOR:+ by $PR_AUTHOR})" >> "$GITHUB_STEP_SUMMARY" + + main-ref: + needs: [authz] + runs-on: ubuntu-24.04 + permissions: {} + steps: + - name: Require the main branch before requesting runners + env: + REF: ${{ github.ref }} + run: | + set -eu + [ "$REF" = refs/heads/main ] + + workflow-lint: + name: workflow-lint + needs: [authz, main-ref] + runs-on: gke-2cpu-4gb-amd64 + permissions: + contents: read + steps: + - name: "Check out the exact head with full history" + if: "${{ needs.authz.result == 'success' }}" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: "${{ github.event.pull_request.head.sha || github.sha }}" + fetch-depth: 0 + persist-credentials: false + - name: "Lint all workflow definitions with pinned actionlint v1.7.12" + if: "${{ needs.authz.result == 'success' }}" + shell: "bash" + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) + archive=actionlint_1.7.12_linux_amd64.tar.gz + sha256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ;; + aarch64|arm64) + archive=actionlint_1.7.12_linux_arm64.tar.gz + sha256=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 ;; + *) + echo "::error::workflow-lint requires a supported Linux architecture" + exit 1 ;; + esac + lint_dir=$(mktemp -d "$RUNNER_TEMP/workflow-lint.XXXXXXXX") + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 --retry 3 --retry-all-errors \ + --output "$lint_dir/$archive" "https://github.com/rhysd/actionlint/releases/download/v1.7.12/$archive" + if ! printf '%s %s\n' "$sha256" "$lint_dir/$archive" | sha256sum --check --strict --status; then + echo "::error::actionlint archive checksum did not match the published pin" + exit 1 + fi + tar -xzf "$lint_dir/$archive" -C "$lint_dir" actionlint + "$lint_dir/actionlint" -version + printf '%s\n' '{"self-hosted-runner":{"labels":["pf-gcp-pr","pf-gcp-main","pf-gcp-post","gke-2cpu-4gb-amd64","gke-2cpu-4gb-arm64","gke-4cpu-8gb-amd64","gke-4cpu-8gb-arm64","gke-8cpu-16gb-amd64","gke-8cpu-16gb-arm64","gke-16cpu-32gb-amd64","gke-16cpu-32gb-arm64","gke-32cpu-64gb-amd64","gke-32cpu-64gb-arm64","gkp-2cpu-4gb-amd64","gkp-2cpu-4gb-arm64","gkp-4cpu-8gb-amd64","gkp-4cpu-8gb-arm64","gkp-8cpu-16gb-amd64","gkp-8cpu-16gb-arm64","gkp-16cpu-32gb-amd64","gkp-16cpu-32gb-arm64","gkp-32cpu-64gb-amd64","gkp-32cpu-64gb-arm64"]}}' > "$lint_dir/actionlint.yaml" + git ls-files -z -- .github/workflows > "$lint_dir/workflows" + workflows=() + while IFS= read -r -d '' path; do + case "$path" in + *.yml|*.yaml) workflows+=("$path") ;; + esac + done < "$lint_dir/workflows" + if [[ -n "${path-}" ]]; then + echo "::error::unterminated Git workflow path" + exit 1 + fi + if [[ "${#workflows[@]}" -eq 0 ]]; then + echo "::error::no workflow YAML files were found" + exit 1 + fi + "$lint_dir/actionlint" -config-file "$lint_dir/actionlint.yaml" -shellcheck= -pyflakes= "${workflows[@]}" + + test: + name: Python ${{ matrix.python-version }} + needs: [authz, main-ref] + runs-on: gke-4cpu-8gb-amd64 + env: + PF_CI_LABELS: '["gke-4cpu-8gb-amd64"]' + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: ${{ matrix.python-version }} + cache: pip + - run: python -m pip install --upgrade pip + - run: python -m pip install -e '.[dev,ccxt,release]' + - run: ruff check . + - run: mypy src + - run: pytest + - if: matrix.python-version == '3.13' + run: python -m build + - if: matrix.python-version == '3.13' + name: The sdist does not ship the native feed + run: | + if tar -tzf dist/*.tar.gz | grep -E '^[^/]+/native/'; then + echo "native/ must stay out of the Python sdist" >&2 + exit 1 + fi + - if: matrix.python-version == '3.13' + run: python -m twine check --strict dist/* + + docker: + name: Docker runtime + needs: [authz, main-ref] + runs-on: ubuntu-24.04 + env: + PF_CI_LABELS: '["ubuntu-24.04"]' + steps: + - name: authz must have passed + if: needs.authz.result != 'success' + run: exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.authz.result == 'success' + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install -e '.[dev]' + if: needs.authz.result == 'success' + - name: Verify the Docker sidecar is available + if: needs.authz.result == 'success' + run: | + docker info + - run: PINEFORGE_DOCKER_TEST=1 pytest tests/test_docker_integration.py --junitxml=docker.xml + if: needs.authz.result == 'success' + - name: Require every real runtime test with zero skips + if: needs.authz.result == 'success' + env: + PYTHONPATH: scripts + run: python -m release_chain.evidence junit --suite docker --path docker.xml + + database-e2e: + name: Database E2E + needs: [authz, main-ref] + runs-on: ubuntu-24.04 + services: + mysql: + image: mysql:8.4 + env: + MYSQL_DATABASE: pineforge + MYSQL_USER: pineforge + MYSQL_PASSWORD: pineforge + MYSQL_ROOT_PASSWORD: pineforge-root + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping --host=127.0.0.1 --user=pineforge --password=pineforge --silent" + --health-interval=2s + --health-timeout=5s + --health-retries=30 + --health-start-period=15s + postgres: + image: postgres:17 + env: + POSTGRES_DB: pineforge + POSTGRES_USER: pineforge + POSTGRES_PASSWORD: pineforge + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready --username=pineforge --dbname=pineforge" + --health-interval=2s + --health-timeout=5s + --health-retries=30 + --health-start-period=10s + env: + PF_CI_LABELS: '["ubuntu-24.04"]' + PINEFORGE_DATABASE_E2E: "1" + PINEFORGE_CORPUS_OHLCV: tests/fixtures/corpus_ohlcv_ETH-USDT-USDT_1m_sample.csv + PINEFORGE_MYSQL_URL: mysql+pymysql://pineforge:pineforge@127.0.0.1:3306/pineforge + PINEFORGE_POSTGRES_URL: postgresql+psycopg://pineforge:pineforge@127.0.0.1:5432/pineforge + steps: + - name: authz must have passed + if: needs.authz.result != 'success' + run: exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.authz.result == 'success' + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.13" + cache: pip + - run: python -m pip install --upgrade pip + if: needs.authz.result == 'success' + - run: python -m pip install -e '.[dev,database-e2e]' + if: needs.authz.result == 'success' + - run: pytest tests/test_database_e2e.py --junitxml=database.xml + if: needs.authz.result == 'success' + - name: Require the disposable database E2E inventory with zero skips + if: needs.authz.result == 'success' + env: + PYTHONPATH: scripts + run: python -m release_chain.evidence junit --suite database --path database.xml + + contract: + name: Release contract + needs: [authz, main-ref] + runs-on: gke-2cpu-4gb-amd64 + env: + PF_CI_LABELS: '["gke-2cpu-4gb-amd64"]' + steps: + - name: authz must have passed + if: needs.authz.result != 'success' + run: exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.authz.result == 'success' + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.13' + - name: Verify synchronized defaults, immutable manifest, source, facts and both OCI children + if: needs.authz.result == 'success' + env: + GH_TOKEN: ${{ github.token }} + run: python scripts/release_adapter.py local + + distribution: + name: Distribution + needs: [authz, main-ref] + runs-on: ubuntu-24.04 + env: + PF_CI_LABELS: '["ubuntu-24.04"]' + steps: + - name: authz must have passed + if: needs.authz.result != 'success' + run: exit 1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.authz.result == 'success' + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: '3.13' + cache: pip + - run: python -m pip install -e '.[release]' + if: needs.authz.result == 'success' + - name: Set reproducible archive timestamps + if: needs.authz.result == 'success' + run: echo "SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD)" >> "$GITHUB_ENV" + - name: Verify the Docker sidecar is available + if: needs.authz.result == 'success' + run: | + docker info + - run: python -m build + if: needs.authz.result == 'success' + - run: python -m twine check --strict dist/* + if: needs.authz.result == 'success' + - name: The canonical sdist does not ship the native feed + if: needs.authz.result == 'success' + run: | + if tar -tzf dist/*.tar.gz | grep -E '^[^/]+/native/'; then + echo "native/ must stay out of the Python sdist" >&2 + exit 1 + fi + - name: Install and backtest both built distributions without importing checkout source + if: needs.authz.result == 'success' + env: + PYTHONPATH: scripts + run: | + set -euo pipefail + version="$(python scripts/bump_version.py current)" + image="$(python -c 'from pathlib import Path; from release_chain.contract import runtime_pin; print(runtime_pin(Path.cwd()))')" + for artifact in "$GITHUB_WORKSPACE"/dist/*; do + case "$artifact" in + *.whl) kind=wheel ;; + *.tar.gz) kind=sdist ;; + *) exit 1 ;; + esac + environment="$RUNNER_TEMP/distribution-$kind" + python -m venv "$environment" + "$environment/bin/python" -m pip install "$artifact" + "$environment/bin/python" -I scripts/exercise_distribution.py \ + --version "$version" --image "$image" --source "$GITHUB_WORKSPACE/src" \ + --fixtures "$GITHUB_WORKSPACE/tests/fixtures" \ + --output "$RUNNER_TEMP/exercise-$kind.json" + done + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: distribution-runtime-evidence-${{ github.run_attempt }} + path: ${{ runner.temp }}/exercise-*.json + if-no-files-found: error + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: python-package-distributions-${{ github.run_attempt }} + path: dist/ + if-no-files-found: error + + docs-tests: + name: Documentation + needs: [authz, main-ref] + runs-on: gke-2cpu-4gb-amd64 + permissions: + contents: read + env: + PF_CI_LABELS: '["gke-2cpu-4gb-amd64"]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.authz.result == 'success' + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + if: needs.authz.result == 'success' + with: + python-version: '3.13' + - name: Install pytest only; the documentation test reads files + if: needs.authz.result == 'success' + run: python -m pip install pytest + - name: Check every relative documentation link + if: needs.authz.result == 'success' + run: pytest tests/test_documentation.py + + ready: + name: release/ready + needs: [authz, main-ref, test, contract, docker, database-e2e, distribution] + runs-on: gke-2cpu-4gb-amd64 + env: + PF_CI_LABELS: '["gke-2cpu-4gb-amd64"]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: needs.authz.result == 'success' + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + if: needs.authz.result == 'success' + with: + python-version: '3.13' + - name: Download this attempt's tested distributions + if: needs.authz.result == 'success' + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + ATTEMPT: ${{ github.run_attempt }} + run: gh run download "$RUN_ID" --name "python-package-distributions-$ATTEMPT" --dir dist + - name: Require the complete job inventory and bind evidence to this head/base/tree + if: needs.authz.result == 'success' + env: + PYTHONPATH: scripts + NEEDS_JSON: >- + {"test":${{ toJSON(needs.test) }},"contract":${{ toJSON(needs.contract) }}, + "docker":${{ toJSON(needs.docker) }},"database-e2e":${{ toJSON(needs.database-e2e) }}, + "distribution":${{ toJSON(needs.distribution) }}} + GH_TOKEN: ${{ github.token }} + run: python -m release_chain.evidence aggregate --path dist + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: needs.authz.result == 'success' + with: + name: release-evidence-${{ github.run_attempt }} + path: evidence.json + if-no-files-found: error + + check: + needs: [authz, main-ref, workflow-lint, test, docker, database-e2e, contract, distribution, docs-tests, ready] + if: ${{ always() }} + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: "Refuse an unauthorised run before anything else" + if: "${{ needs.authz.result != 'success' }}" + run: | + echo "::error::authz did not pass; nothing of this run's code is checked out or run" + exit 1 + - name: Every needed job succeeded + env: + RESULTS: ${{ toJSON(needs.*.result) }} + run: | + echo "$RESULTS" + [ "$(echo "$RESULTS" | tr -d '[] \n"' | tr ',' '\n' | grep -vc '^success$')" = "0" ] || { echo "::error::a needed job did not succeed"; exit 1; } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ae71d9..90ff5a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,21 +1,14 @@ name: CI on: - push: - branches: [main] pull_request: permissions: contents: read actions: read -# Only a superseded pull_request run is cancelled: every other event (push to main, -# whose run authorizes publication) gets its own run_id group and is never cancelled. -# Every job runs on GitHub-hosted ubuntu-24.04 (owner decision CI-2 = A, 10-21 04:01, and #1050: -# this repository is public and no job here holds a secret); dependabot counts as ours. -# Fork PRs fail authz. Documentation/workflow-only PRs run the light checks and skip -# the heavy suites and release/ready evidence. The required check independently proves -# cheap skips; code PRs and every push run the full suites and produce release evidence. +# Pull requests keep hosted runners and the independently verified cheap checks. +# Main-branch proofs and release evidence run in ci-post.yml. concurrency: group: ci-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -236,7 +229,7 @@ jobs: fi tar -xzf "$lint_dir/$archive" -C "$lint_dir" actionlint "$lint_dir/actionlint" -version - printf '%s\n' '{"self-hosted-runner":{"labels":["pf-gcp-pr","pf-gcp-main","pf-gcp-post"]}}' > "$lint_dir/actionlint.yaml" + printf '%s\n' '{"self-hosted-runner":{"labels":["pf-gcp-pr","pf-gcp-main","pf-gcp-post","gke-2cpu-4gb-amd64","gke-2cpu-4gb-arm64","gke-4cpu-8gb-amd64","gke-4cpu-8gb-arm64","gke-8cpu-16gb-amd64","gke-8cpu-16gb-arm64","gke-16cpu-32gb-amd64","gke-16cpu-32gb-arm64","gke-32cpu-64gb-amd64","gke-32cpu-64gb-arm64","gkp-2cpu-4gb-amd64","gkp-2cpu-4gb-arm64","gkp-4cpu-8gb-amd64","gkp-4cpu-8gb-arm64","gkp-8cpu-16gb-amd64","gkp-8cpu-16gb-arm64","gkp-16cpu-32gb-amd64","gkp-16cpu-32gb-arm64","gkp-32cpu-64gb-amd64","gkp-32cpu-64gb-arm64"]}}' > "$lint_dir/actionlint.yaml" git ls-files -z -- .github/workflows > "$lint_dir/workflows" workflows=() while IFS= read -r -d '' path; do diff --git a/.github/workflows/native-feed-post.yml b/.github/workflows/native-feed-post.yml new file mode 100644 index 0000000..466e1b3 --- /dev/null +++ b/.github/workflows/native-feed-post.yml @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: Apache-2.0 +name: Native feed + +on: + push: + branches: [main] + paths: ['native/feed/**', '.github/workflows/native-feed.yml', '.github/workflows/native-feed-post.yml'] + workflow_dispatch: + +permissions: + contents: read + +# Only main can request pods; macOS remains on its hosted runner. +concurrency: + group: native-feed-post-${{ github.run_id }} + cancel-in-progress: false + +jobs: + authz: + name: authz + # Standard GitHub-hosted label (owner #1050); never a larger-runner label. + runs-on: ubuntu-24.04 + permissions: {} + # No timeout-minutes: the owner's no-time-limits rule (check-no-time-limits refuses new ones). + steps: + - name: Only our own pull requests and our own events run CI + env: + EVENT: ${{ github.event_name }} + REPO: ${{ github.repository }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + HEAD_IS_FORK: ${{ github.event.pull_request.head.repo.fork }} + ASSOCIATION: ${{ github.event.pull_request.author_association }} + PR_AUTHOR: ${{ github.event.pull_request.user.login }} + WR_HEAD_REPO: ${{ github.event.workflow_run.head_repository.full_name }} + WR_ACTOR: ${{ github.event.workflow_run.triggering_actor.login }} + GH_TOKEN: ${{ github.token }} # read-only use: one permission lookup for workflow_run + # Bot logins whose same-repository pull requests count as ours: dependabot[bot] (CI-DEPBOT A) + # and the org's own GitHub App bot logins by name (space-separated), nothing else. + ALLOWED_BOTS: "dependabot[bot] pineforge-app[bot]" + run: | + set -eu + fail() { echo "::error title=authz::$1"; echo "authz: REFUSED: $1" >> "$GITHUB_STEP_SUMMARY"; exit 1; } + case "$EVENT" in + push|workflow_dispatch|schedule|merge_group|release|workflow_call|repository_dispatch) + # Only someone with write access to this repository (or our App's token) can cause these + # events; none of them runs pull-request code. + ;; + workflow_run) + # LB-437: bind identity to the triggering run and its re-runner. + # These two App bots are fixed canon policy; ALLOWED_BOTS is PR-only. + [ -n "${WR_HEAD_REPO:-}" ] || fail "workflow_run head repository is missing" + [ -n "${WR_ACTOR:-}" ] || fail "workflow_run triggering actor is missing" + [ "$WR_HEAD_REPO" = "$REPO" ] || fail "workflow_run head repository does not match this repository" + case "$WR_ACTOR" in + "pineforge-app[bot]"|"pineforge-ci-dispatcher[bot]") ;; + *"[bot]") fail "workflow_run bot is not on the canon allowlist" ;; + *) + perm=$(gh api "repos/$REPO/collaborators/$WR_ACTOR/permission" --jq .permission 2>/dev/null) || fail "workflow_run permission lookup failed" + case "$perm" in admin|maintain|write) ;; *) fail "workflow_run triggering actor does not have write permission" ;; esac ;; + esac ;; + pull_request) + [ "$HEAD_REPO" = "$REPO" ] || fail "pull request from another repository (${HEAD_REPO:-unknown})" + [ "$HEAD_IS_FORK" = "false" ] || fail "pull request from a fork" + case "$ASSOCIATION" in + OWNER|MEMBER|COLLABORATOR) ;; + *) + case " $ALLOWED_BOTS " in + *" $PR_AUTHOR "*) ;; + *) + # A member whose org membership is private shows as CONTRIBUTOR: ask GitHub for the + # author's permission on this repository; anything but admin/maintain/write (or a + # failed call) refuses. + perm=$(gh api "repos/$REPO/collaborators/$PR_AUTHOR/permission" --jq .permission 2>/dev/null || echo none) + case "$perm" in admin|maintain|write) ;; *) fail "author ${PR_AUTHOR:-unknown} (${ASSOCIATION:-NONE}) has permission '$perm', not write, and is not an allowlisted bot" ;; esac ;; + esac ;; + esac ;; + *) fail "event $EVENT does not run CI (pull_request_target and other events are not allowed)" ;; + esac + echo "authz: allowed ($EVENT${PR_AUTHOR:+ by $PR_AUTHOR})" >> "$GITHUB_STEP_SUMMARY" + + main-ref: + needs: [authz] + runs-on: ubuntu-24.04 + permissions: {} + steps: + - name: Require the main branch before requesting runners + env: + REF: ${{ github.ref }} + run: | + set -eu + [ "$REF" = refs/heads/main ] + + release-amd64: + name: Release (ubuntu-24.04) + needs: [authz, main-ref] + runs-on: gke-4cpu-8gb-amd64 + env: + PF_CI_LABELS: '["gke-4cpu-8gb-amd64"]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Linux prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build libssl-dev libdigest-sha-perl + - name: macOS prerequisites + if: runner.os == 'macOS' + run: | + brew install ninja openssl@3 + echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" >> "$GITHUB_ENV" + - name: Pinned WebSocket curl + run: bash native/feed/tests/build_ci_curl.sh "$RUNNER_TEMP/curl" + - name: Configure, build and test + run: | + export LD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${LD_LIBRARY_PATH:-}" + export DYLD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${DYLD_LIBRARY_PATH:-}" + cmake -S native/feed -B build-feed -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCURL_DIR="$RUNNER_TEMP/curl/lib/cmake/CURL" + cmake --build build-feed -j4 + ctest --test-dir build-feed --output-on-failure + + release-arm64: + name: Release (ubuntu-24.04-arm) + needs: [authz, main-ref] + runs-on: gke-4cpu-8gb-arm64 + env: + PF_CI_LABELS: '["gke-4cpu-8gb-arm64"]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Linux prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build libssl-dev libdigest-sha-perl + - name: macOS prerequisites + if: runner.os == 'macOS' + run: | + brew install ninja openssl@3 + echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" >> "$GITHUB_ENV" + - name: Pinned WebSocket curl + run: bash native/feed/tests/build_ci_curl.sh "$RUNNER_TEMP/curl" + - name: Configure, build and test + run: | + export LD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${LD_LIBRARY_PATH:-}" + export DYLD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${DYLD_LIBRARY_PATH:-}" + cmake -S native/feed -B build-feed -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCURL_DIR="$RUNNER_TEMP/curl/lib/cmake/CURL" + cmake --build build-feed -j4 + ctest --test-dir build-feed --output-on-failure + + release-macos: + name: Release (macos-14) + needs: [authz, main-ref] + runs-on: macos-14 + env: + PF_CI_LABELS: '["macos-14"]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Linux prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y cmake ninja-build libssl-dev libdigest-sha-perl + - name: macOS prerequisites + if: runner.os == 'macOS' + run: | + brew install ninja openssl@3 + echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" >> "$GITHUB_ENV" + - name: Pinned WebSocket curl + run: bash native/feed/tests/build_ci_curl.sh "$RUNNER_TEMP/curl" + - name: Configure, build and test + run: | + export LD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${LD_LIBRARY_PATH:-}" + export DYLD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${DYLD_LIBRARY_PATH:-}" + cmake -S native/feed -B build-feed -G Ninja -DCMAKE_BUILD_TYPE=Release \ + -DCURL_DIR="$RUNNER_TEMP/curl/lib/cmake/CURL" + cmake --build build-feed -j4 + ctest --test-dir build-feed --output-on-failure + + asan-ubsan: + name: ASan + UBSan + needs: [authz, main-ref] + runs-on: gke-4cpu-8gb-amd64 + env: + PF_CI_LABELS: '["gke-4cpu-8gb-amd64"]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: sudo apt-get update && sudo apt-get install -y ninja-build libssl-dev clang-18 libdigest-sha-perl + - run: bash native/feed/tests/build_ci_curl.sh "$RUNNER_TEMP/curl" + - name: Instrumented core and mock venue + env: + ASAN_OPTIONS: detect_leaks=1:abort_on_error=1 + UBSAN_OPTIONS: halt_on_error=1:print_stacktrace=1 + run: | + export LD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${LD_LIBRARY_PATH:-}" + cmake -S native/feed -B build-feed -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_C_COMPILER=clang-18 -DCURL_DIR="$RUNNER_TEMP/curl/lib/cmake/CURL" \ + -DPINEFORGE_FEED_SANITIZER=address,undefined + cmake --build build-feed -j4 + ctest --test-dir build-feed --output-on-failure + + tsan: + name: TSan + needs: [authz, main-ref] + runs-on: gke-4cpu-8gb-amd64 + env: + PF_CI_LABELS: '["gke-4cpu-8gb-amd64"]' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - run: sudo apt-get update && sudo apt-get install -y ninja-build libssl-dev clang-18 llvm-18 libdigest-sha-perl + - run: bash native/feed/tests/build_ci_curl.sh "$RUNNER_TEMP/curl" + - name: Check TSan symbolizer + run: | + # With no symbolizer configured, TSan can fall back and still symbolize. + # A wrong explicit path breaks symbolization and makes race:STOP_FLAG_ASSIGN inert. + if ! test -x /usr/lib/llvm-18/bin/llvm-symbolizer; then + echo "::error::TSan requires an executable /usr/lib/llvm-18/bin/llvm-symbolizer for function-name suppressions; install llvm-18." >&2 + exit 1 + fi + - name: Threaded receive, healing, termination and serve fan-out + env: + TSAN_OPTIONS: halt_on_error=1 + run: | + export LD_LIBRARY_PATH="$RUNNER_TEMP/curl/lib:${LD_LIBRARY_PATH:-}" + cmake -S native/feed -B build-feed -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_C_COMPILER=clang-18 -DCURL_DIR="$RUNNER_TEMP/curl/lib/cmake/CURL" \ + -DPINEFORGE_FEED_SANITIZER=thread \ + -DPINEFORGE_FEED_TSAN_OPTIONS_EXTRA=external_symbolizer_path=/usr/lib/llvm-18/bin/llvm-symbolizer + cmake --build build-feed -j4 + ctest --test-dir build-feed --output-on-failure diff --git a/.github/workflows/native-feed.yml b/.github/workflows/native-feed.yml index 4d10768..8535867 100644 --- a/.github/workflows/native-feed.yml +++ b/.github/workflows/native-feed.yml @@ -1,20 +1,14 @@ # SPDX-License-Identifier: Apache-2.0 name: Native feed on: - push: - branches: [main] - paths: ['native/feed/**', '.github/workflows/native-feed.yml'] pull_request: - paths: ['native/feed/**', '.github/workflows/native-feed.yml'] - workflow_dispatch: + paths: ['native/feed/**', '.github/workflows/native-feed.yml', '.github/workflows/native-feed-post.yml'] permissions: contents: read -# Only a superseded pull_request run is cancelled; push and dispatch are never cancelled. -# authz, asan-ubsan, tsan and the release legs run on GitHub-hosted runners (owner decision -# CI-2 = A, 10-21: ubuntu-24.04, ubuntu-24.04-arm, macos-14; no secret in any). -# Fork PRs fail authz. +# Pull request builds keep their existing hosted platform runners. +# Main-branch builds run in native-feed-post.yml; macOS remains hosted. concurrency: group: native-feed-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} diff --git a/docs/releasing.md b/docs/releasing.md index be5daab..2937d2f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -83,7 +83,7 @@ static checks, for example Ruff, mypy, Python compilation, actionlint and ## Publication, completion and repair Successful PR CI wakes the reconciler through `workflow_run`. The App's merge -triggers ordinary main CI. `Release` wakes on completion of that main CI, and +triggers `ci-post.yml` on main. `Release` wakes on completion of that main CI, and accepts only a landed App-created release/version/rollback PR. Ordinary main pushes are not publication requests. Failed main CI cannot authorize upload. @@ -112,7 +112,7 @@ produce no receipt; ordinary manual package publication still uses the existing landed CI evidence and gates, without requiring a product receipt artifact. CI/PR-head code never builds the hub artifact. CI and E2E both reference the complete successful **pre-merge** `ci.yml` run, -including `release/ready`. The landed main CI still authorizes the exact +including `release/ready`. The landed `ci-post.yml` run authorizes the exact wheel/sdist bytes used by the existing publisher. Preview records the candidate head/image/facts and a SHA-256 of its tested distribution identity. After the `Release` run finishes, the coordinator rechecks the actual PR, tested base and diff --git a/scripts/release_chain/evidence.py b/scripts/release_chain/evidence.py index 32275e2..d876223 100644 --- a/scripts/release_chain/evidence.py +++ b/scripts/release_chain/evidence.py @@ -13,10 +13,13 @@ from .contract import gh_bytes, json_bytes, object_json, require, sha256 -# SHA256 of reviewed .github/workflows/ci.yml bytes: no continue-on-error, matrix leg names = JOBS. +# SHA256 of both reviewed CI definitions: no continue-on-error, matrix leg names = JOBS. # Edits must update the pin in the same PR after re-review: needs..result == "success" proves # every leg succeeded (2026-10-23, VP-145 review P0: a lexical test alone misses escaped YAML keys). -REVIEWED_CI_WORKFLOW_SHA256 = "de94f322b372305ccb4e5550e248c9727137f263f17dc3d6c4649a5d09ef64c5" +REVIEWED_CI_WORKFLOW_SHA256 = "d62bd2fa2c574d336654df1a18988eb8692396cf75d21dc9f6df0d59735f3225" +REVIEWED_CI_POST_WORKFLOW_SHA256 = ( + "0d482244dd46ed3e4ec7ac6e5297521c81106922a93c24fde01ed1e87d2fdd5d" +) JOBS = ( "Python 3.11", @@ -102,15 +105,19 @@ def validate_inventory(jobs: list[dict[str, Any]]) -> None: def require_no_continue_on_error(root: Path) -> None: - try: - raw = (root / ".github" / "workflows" / "ci.yml").read_bytes() - text = raw.decode("utf-8") - except (OSError, UnicodeError) as exc: - require(False, f"cannot read ci.yml: {exc}") - require( - sha256(raw) == REVIEWED_CI_WORKFLOW_SHA256 and "continue-on-error" not in text, - "ci.yml changed or uses continue-on-error: needs results require workflow review", - ) + for filename, reviewed in ( + ("ci.yml", REVIEWED_CI_WORKFLOW_SHA256), + ("ci-post.yml", REVIEWED_CI_POST_WORKFLOW_SHA256), + ): + try: + raw = (root / ".github/workflows" / filename).read_bytes() + text = raw.decode("utf-8") + except (OSError, UnicodeError) as exc: + require(False, f"cannot read {filename}: {exc}") + require( + sha256(raw) == reviewed and "continue-on-error" not in text, + f"{filename} changed or uses continue-on-error: needs results require workflow review", + ) def create_evidence(root: Path, directory: Path, needs: dict[str, Any]) -> dict[str, Any]: @@ -161,7 +168,8 @@ def validate_gate( run["status"] == "completed" and run["conclusion"] == "success", "CI has not completed successfully", ) - require(run["path"] == ".github/workflows/ci.yml", "unexpected CI workflow") + workflow = "ci.yml" if pr else "ci-post.yml" + require(run["path"] == f".github/workflows/{workflow}", "unexpected CI workflow") require(run["head_sha"] == head and evidence["head"] == head, "stale CI head") require(evidence["base"] == base and evidence["pr"] == pr, "stale CI base or PR") require( diff --git a/scripts/release_chain/publishing.py b/scripts/release_chain/publishing.py index 0781ec5..b605b54 100644 --- a/scripts/release_chain/publishing.py +++ b/scripts/release_chain/publishing.py @@ -298,7 +298,8 @@ def recover_applied(repo: str, source: Path, report: dict[str, Any]) -> None: report["pr"]["base_sha"] = marker(pr)["base"] runs = api( repo, - "actions/workflows/ci.yml/runs?event=push&per_page=100&head_sha=" + pr["merge_commit_sha"], + "actions/workflows/ci-post.yml/runs?event=push&per_page=100&head_sha=" + + pr["merge_commit_sha"], )["workflow_runs"] runs = [run for run in runs if run["head_sha"] == pr["merge_commit_sha"]] if not runs or max(runs, key=lambda run: run["id"])["status"] != "completed": diff --git a/tests/fixtures/ci_canon/canon-workflow-lint-gke-job.yml b/tests/fixtures/ci_canon/canon-workflow-lint-gke-job.yml new file mode 100644 index 0000000..e780269 --- /dev/null +++ b/tests/fixtures/ci_canon/canon-workflow-lint-gke-job.yml @@ -0,0 +1,57 @@ +jobs: + workflow-lint: + name: workflow-lint + needs: [authz] + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - name: "Check out the exact head with full history" + if: "${{ needs.authz.result == 'success' }}" + uses: "actions/checkout@v7" + with: + ref: "${{ github.event.pull_request.head.sha || github.sha }}" + fetch-depth: 0 + persist-credentials: false + - name: "Lint all workflow definitions with pinned actionlint v1.7.12" + if: "${{ needs.authz.result == 'success' }}" + shell: "bash" + run: | + set -euo pipefail + case "$(uname -m)" in + x86_64) + archive=actionlint_1.7.12_linux_amd64.tar.gz + sha256=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ;; + aarch64|arm64) + archive=actionlint_1.7.12_linux_arm64.tar.gz + sha256=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 ;; + *) + echo "::error::workflow-lint requires a supported Linux architecture" + exit 1 ;; + esac + lint_dir=$(mktemp -d "$RUNNER_TEMP/workflow-lint.XXXXXXXX") + curl --fail --silent --show-error --location --proto '=https' --tlsv1.2 --retry 3 --retry-all-errors --speed-limit 1024 --speed-time 60 \ + --output "$lint_dir/$archive" "https://github.com/rhysd/actionlint/releases/download/v1.7.12/$archive" + if ! printf '%s %s\n' "$sha256" "$lint_dir/$archive" | sha256sum --check --strict --status; then + echo "::error::actionlint archive checksum did not match the published pin" + exit 1 + fi + tar -xzf "$lint_dir/$archive" -C "$lint_dir" actionlint + "$lint_dir/actionlint" -version + printf '%s\n' '{"self-hosted-runner":{"labels":["pf-gcp-pr","pf-gcp-main","pf-gcp-post","gke-2cpu-4gb-amd64","gke-2cpu-4gb-arm64","gke-4cpu-8gb-amd64","gke-4cpu-8gb-arm64","gke-8cpu-16gb-amd64","gke-8cpu-16gb-arm64","gke-16cpu-32gb-amd64","gke-16cpu-32gb-arm64","gke-32cpu-64gb-amd64","gke-32cpu-64gb-arm64","gkp-2cpu-4gb-amd64","gkp-2cpu-4gb-arm64","gkp-4cpu-8gb-amd64","gkp-4cpu-8gb-arm64","gkp-8cpu-16gb-amd64","gkp-8cpu-16gb-arm64","gkp-16cpu-32gb-amd64","gkp-16cpu-32gb-arm64","gkp-32cpu-64gb-amd64","gkp-32cpu-64gb-arm64"]}}' > "$lint_dir/actionlint.yaml" + git ls-files -z -- .github/workflows > "$lint_dir/workflows" + workflows=() + while IFS= read -r -d '' path; do + case "$path" in + *.yml|*.yaml) workflows+=("$path") ;; + esac + done < "$lint_dir/workflows" + if [[ -n "${path-}" ]]; then + echo "::error::unterminated Git workflow path" + exit 1 + fi + if [[ "${#workflows[@]}" -eq 0 ]]; then + echo "::error::no workflow YAML files were found" + exit 1 + fi + "$lint_dir/actionlint" -config-file "$lint_dir/actionlint.yaml" -shellcheck= -pyflakes= "${workflows[@]}" diff --git a/tests/test_post_merge_workflows.py b/tests/test_post_merge_workflows.py new file mode 100644 index 0000000..107892e --- /dev/null +++ b/tests/test_post_merge_workflows.py @@ -0,0 +1,331 @@ +"""Keep pull request code hosted and run the complete landed proofs on main.""" + +from __future__ import annotations + +import copy +import itertools +import json +import os +import subprocess +from collections import Counter +from pathlib import Path +from typing import Any + +import pytest +import yaml +from release_chain import evidence + +ROOT = Path(__file__).resolve().parents[1] +WORKFLOWS = ROOT / ".github/workflows" +PAIRS = ("ci", "native-feed") +ROUTES = { + "workflow-lint": "gke-2cpu-4gb-amd64", + "test": "gke-4cpu-8gb-amd64", + "docker": "ubuntu-24.04", + "database-e2e": "ubuntu-24.04", + "contract": "gke-2cpu-4gb-amd64", + "distribution": "ubuntu-24.04", + "docs-tests": "gke-2cpu-4gb-amd64", + "ready": "gke-2cpu-4gb-amd64", +} +NATIVE_ROUTES = { + "release-amd64": ("Release (ubuntu-24.04)", "gke-4cpu-8gb-amd64"), + "release-arm64": ("Release (ubuntu-24.04-arm)", "gke-4cpu-8gb-arm64"), + "release-macos": ("Release (macos-14)", "macos-14"), + "asan-ubsan": ("ASan + UBSan", "gke-4cpu-8gb-amd64"), + "tsan": ("TSan", "gke-4cpu-8gb-amd64"), +} +PINS = { + "actions/checkout@v7": "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/setup-python@v6": "actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1", + "actions/upload-artifact@v5": ( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + ), +} + + +def load(stem: str, *, post: bool = False) -> dict[str, Any]: + path = WORKFLOWS / f"{stem}{'-post' if post else ''}.yml" + # On the unsplit revision inspect the actual mixed workflow, so regressions + # fail on its event/routing behavior rather than on a missing successor file. + if not path.exists(): + path = WORKFLOWS / f"{stem}.yml" + return yaml.load(path.read_text(), Loader=yaml.BaseLoader) + + +def needs(job: dict[str, Any]) -> set[str]: + value = job.get("needs", []) + return {value} if isinstance(value, str) else set(value) + + +def assert_post_events(workflow: dict[str, Any]) -> None: + assert set(workflow["on"]) == {"push", "workflow_dispatch"} + assert workflow["on"]["push"]["branches"] == ["main"] + assert workflow["concurrency"]["cancel-in-progress"] == "false" + + +@pytest.mark.parametrize("stem", PAIRS) +def test_pr_workflows_have_only_the_pull_request_trigger(stem: str) -> None: + assert set(load(stem)["on"]) == {"pull_request"} + + +@pytest.mark.parametrize("stem", PAIRS) +def test_post_workflows_have_only_main_push_and_dispatch(stem: str) -> None: + assert_post_events(load(stem, post=True)) + + +@pytest.mark.parametrize("stem", PAIRS) +@pytest.mark.parametrize("event", ["pull_request", "pull_request_target", "pull_request_review"]) +def test_post_event_control_rejects_every_pr_event(stem: str, event: str) -> None: + original = load(stem, post=True) + assert_post_events(original) + changed = copy.deepcopy(original) + changed["on"][event] = "" + with pytest.raises(AssertionError): + assert_post_events(changed) + + +def test_native_paths_cover_both_workflow_definitions() -> None: + paths = [ + "native/feed/**", + ".github/workflows/native-feed.yml", + ".github/workflows/native-feed-post.yml", + ] + assert load("native-feed")["on"]["pull_request"]["paths"] == paths + assert load("native-feed", post=True)["on"]["push"]["paths"] == paths + + +def assert_post_ci(workflow: dict[str, Any]) -> None: + jobs = workflow["jobs"] + assert "changes" not in jobs + assert set(jobs) == {"authz", "main-ref", "check", *ROUTES} + for job_id, route in ROUTES.items(): + job = jobs[job_id] + assert job["runs-on"] == route, job_id + assert "if" not in job, job_id + expected = {"authz", "main-ref"} + if job_id == "ready": + expected |= evidence.NEEDS + assert needs(job) == expected, job_id + check = jobs["check"] + assert check["if"] == "${{ always() }}" + assert needs(check) == set(jobs) - {"check"} + assert len(check["needs"]) == len(needs(check)) + assert check["runs-on"] == "ubuntu-24.04" + assert len(check["steps"]) == 2 + refusal, gate = check["steps"] + assert refusal["if"] == "${{ needs.authz.result != 'success' }}" + assert "exit 1" in refusal["run"] + assert gate["env"] == {"RESULTS": "${{ toJSON(needs.*.result) }}"} + assert "changes" not in json.dumps(check) + assert jobs["test"]["strategy"]["matrix"]["python-version"] == ["3.11", "3.12", "3.13"] + assert workflow["name"] == load("ci")["name"] == "CI" + for job_id in ROUTES: + assert jobs[job_id].get("name", job_id) == load("ci")["jobs"][job_id].get("name", job_id) + if job_id == "workflow-lint": + expected = copy.deepcopy(load("ci")["jobs"][job_id]["steps"]) + expected[0]["uses"] = PINS[expected[0]["uses"]] + expected[1]["run"] = expected[1]["run"].replace( + " --speed-limit 1024 --speed-time 60", "" + ) + assert jobs[job_id]["steps"] == expected + continue + expected = copy.deepcopy(load("ci")["jobs"][job_id]["steps"]) + for step in expected: + if step.get("uses") in PINS: + step["uses"] = PINS[step["uses"]] + if job_id in {"docker", "distribution"}: + before = next( + index for index, step in enumerate(expected) + if "pytest" in step.get("run", "") or step.get("run") == "python -m build" + ) + expected.insert(before, { + "name": "Verify the Docker sidecar is available", + "if": "needs.authz.result == 'success'", + "run": "docker info\n", + }) + if job_id == "ready": + expected[2] = { + "name": "Download this attempt's tested distributions", + "if": "needs.authz.result == 'success'", + "env": { + "GH_TOKEN": "${{ github.token }}", + "GH_REPO": "${{ github.repository }}", + "RUN_ID": "${{ github.run_id }}", + "ATTEMPT": "${{ github.run_attempt }}", + }, + "run": ( + 'gh run download "$RUN_ID" --name ' + '"python-package-distributions-$ATTEMPT" --dir dist' + ), + } + assert jobs[job_id]["steps"] == expected, job_id + + +def test_post_ci_has_every_proof_without_pr_classification() -> None: + assert_post_ci(load("ci", post=True)) + + +@pytest.mark.parametrize("fault", ["classifier", "skip", "dependency", "gate", "runner"]) +def test_post_ci_control_rejects_reverting_a_proof(fault: str) -> None: + original = load("ci", post=True) + assert_post_ci(original) + changed = copy.deepcopy(original) + jobs = changed["jobs"] + if fault == "classifier": + jobs["changes"] = copy.deepcopy(load("ci")["jobs"]["changes"]) + elif fault == "skip": + jobs["test"]["if"] = "needs.changes.outputs.code == 'true'" + elif fault == "dependency": + jobs["check"]["needs"].remove("ready") + elif fault == "gate": + jobs["check"]["if"] = "${{ !cancelled() }}" + else: + jobs["ready"]["runs-on"] = "ubuntu-24.04" + with pytest.raises(AssertionError): + assert_post_ci(changed) + + +def assert_native_post(workflow: dict[str, Any]) -> None: + jobs = workflow["jobs"] + assert set(jobs) == {"authz", "main-ref", *NATIVE_ROUTES} + for job_id, (name, route) in NATIVE_ROUTES.items(): + job = jobs[job_id] + assert job["name"] == name + assert job["runs-on"] == route + assert needs(job) == {"authz", "main-ref"} + assert "if" not in job + # All three existing platform check names survive the scalar-runner split. + original = load("native-feed")["jobs"]["release"] + assert {f"Release ({os_name})" for os_name in original["strategy"]["matrix"]["os"]} == { + name for name, _ in NATIVE_ROUTES.values() if name.startswith("Release (") + } + for job_id in ("release-amd64", "release-arm64", "release-macos"): + expected = copy.deepcopy(original["steps"]) + expected[0]["uses"] = PINS[expected[0]["uses"]] + expected[1]["run"] += " libdigest-sha-perl" + assert jobs[job_id]["steps"] == expected + for job_id in ("asan-ubsan", "tsan"): + expected = copy.deepcopy(load("native-feed")["jobs"][job_id]["steps"][1:]) + expected[0]["run"] += " libdigest-sha-perl" + assert jobs[job_id]["steps"][1:] == expected + + +def test_native_post_keeps_linux_proofs_and_the_hosted_macos_leg() -> None: + assert_native_post(load("native-feed", post=True)) + + +def test_native_pods_install_the_curl_scripts_checksum_tool() -> None: + jobs = load("native-feed", post=True)["jobs"] + for job_id, job in jobs.items(): + if job_id in {"authz", "main-ref", "release-macos"}: + continue + commands = "\n".join(step.get("run", "") for step in job["steps"]) + assert "libdigest-sha-perl" in commands + assert commands.index("libdigest-sha-perl") < commands.index("build_ci_curl.sh") + + +@pytest.mark.parametrize("job_id", NATIVE_ROUTES) +def test_native_control_rejects_a_missing_or_rerouted_leg(job_id: str) -> None: + original = load("native-feed", post=True) + assert_native_post(original) + for fault in ("missing", "runner"): + changed = copy.deepcopy(original) + if fault == "missing": + del changed["jobs"][job_id] + else: + changed["jobs"][job_id]["runs-on"] = "ubuntu-latest" + with pytest.raises(AssertionError): + assert_native_post(changed) + + +@pytest.mark.parametrize("stem", PAIRS) +def test_post_ref_is_refused_on_hosted_before_a_pod_is_requested(stem: str) -> None: + jobs = load(stem, post=True)["jobs"] + assert "main-ref" in jobs + guard = jobs["main-ref"] + assert guard["runs-on"] == "ubuntu-24.04" + assert needs(guard) == {"authz"} + assert "if" not in guard + assert len(guard["steps"]) == 1 + step = guard["steps"][0] + assert step["env"] == {"REF": "${{ github.ref }}"} + assert step["run"] == 'set -eu\n[ "$REF" = refs/heads/main ]\n' + for job_id, job in jobs.items(): + if job_id not in {"authz", "main-ref"}: + assert "main-ref" in needs(job) + + +@pytest.mark.parametrize("stem", PAIRS) +def test_post_authz_stays_canonical_and_pods_hold_no_credentials(stem: str) -> None: + workflow = load(stem, post=True) + assert workflow["jobs"]["authz"] == load(stem)["jobs"]["authz"] + assert set(workflow["permissions"].values()) == {"read"} + for job in workflow["jobs"].values(): + if str(job["runs-on"]).startswith("gke-"): + assert "environment" not in job + assert "secrets." not in json.dumps(job) + assert "write" not in job.get("permissions", {}).values() + assert "container" not in job + assert "services" not in job + + +@pytest.mark.parametrize("stem", PAIRS) +def test_post_workflow_caps_each_label_at_eight_pods(stem: str) -> None: + counts: Counter[str] = Counter() + for job in load(stem, post=True)["jobs"].values(): + route = job["runs-on"] + if not isinstance(route, str) or not route.startswith("gke-"): + continue + matrix = job.get("strategy", {}).get("matrix", {}) + axes = [values for key, values in matrix.items() if key not in {"include", "exclude"}] + legs = len(list(itertools.product(*axes))) if axes else 1 + counts[route] += legs + assert counts + assert max(counts.values()) <= 8 + + +@pytest.mark.parametrize("job_id", ["docker", "distribution"]) +def test_docker_consumers_check_the_sidecar_before_using_it(job_id: str) -> None: + job = load("ci", post=True)["jobs"][job_id] + commands = [step.get("run", "") for step in job["steps"]] + assert "docker info\n" in commands + assert commands.index("docker info\n") < next( + index for index, command in enumerate(commands) + if "pytest" in command or "python -m build" in command + ) + assert load("ci", post=True)["jobs"]["database-e2e"]["runs-on"] == "ubuntu-24.04" + assert load("ci", post=True)["jobs"]["database-e2e"]["services"] == ( + load("ci")["jobs"]["database-e2e"]["services"] + ) + + +def test_post_workflow_bytes_require_independent_review(tmp_path: Path) -> None: + root = tmp_path / "repo" + workflows = root / ".github/workflows" + workflows.mkdir(parents=True) + (workflows / "ci.yml").write_bytes((WORKFLOWS / "ci.yml").read_bytes()) + post = WORKFLOWS / "ci-post.yml" + raw = post.read_bytes() if post.exists() else (WORKFLOWS / "ci.yml").read_bytes() + (workflows / "ci-post.yml").write_bytes(raw + b"\n# unreviewed workflow edit\n") + with pytest.raises(ValueError, match=r"ci-post\.yml changed"): + evidence.require_no_continue_on_error(root) + + +@pytest.mark.parametrize("result", ["success", "failure", "cancelled", "skipped", "unknown"]) +def test_post_gate_reads_every_real_dependency_result(tmp_path: Path, result: str) -> None: + gate = load("ci", post=True)["jobs"]["check"] + assert len(gate["steps"]) == 2 + script = gate["steps"][-1]["run"] + for dependency in gate["needs"]: + results = dict.fromkeys(gate["needs"], "success") + results[dependency] = result + completed = subprocess.run( + ["bash", "-e", "-o", "pipefail", "-c", script], + cwd=tmp_path, + env={**os.environ, "RESULTS": json.dumps(list(results.values()))}, + capture_output=True, + text=True, + check=False, + ) + assert (completed.returncode == 0) is (result == "success"), dependency diff --git a/tests/test_pr_tier_workflows.py b/tests/test_pr_tier_workflows.py index 0db99c0..1ce53c5 100644 --- a/tests/test_pr_tier_workflows.py +++ b/tests/test_pr_tier_workflows.py @@ -1219,7 +1219,7 @@ def assert_cheap_pr_policy(workflow: dict[str, Any]) -> None: assert isinstance(pull_request, dict) assert not {"paths", "paths-ignore", "branches", "branches-ignore"} & set(pull_request) assert jobs["changes"] == canon_job("canon-changes-job.yml", "changes") - assert jobs["workflow-lint"] == canon_job("canon-workflow-lint-job.yml", "workflow-lint") + assert jobs["workflow-lint"] == canon_job("canon-workflow-lint-gke-job.yml", "workflow-lint") example = canon_job("canon-heavy-job-example.yml", "shard-a") for job_id in ("test", *RUNTIME_JOBS): assert jobs[job_id]["needs"] == example["needs"] diff --git a/tests/test_release_adapter.py b/tests/test_release_adapter.py index 203c101..3d00849 100644 --- a/tests/test_release_adapter.py +++ b/tests/test_release_adapter.py @@ -381,6 +381,54 @@ def test_gate_accepts_only_the_complete_current_attempt() -> None: evidence.validate_gate(gate, run, jobs, head="a" * 40, base="b" * 40, pr=7) +def test_landed_evidence_requires_the_post_workflow() -> None: + gate, run, jobs = green_gate() + gate.update(event="push", pr=None, base=None) + run.update(event="push", path=".github/workflows/ci-post.yml") + evidence.validate_gate(gate, run, jobs, head="a" * 40, base=None, pr=None) + run["path"] = ".github/workflows/ci.yml" + with pytest.raises(ValueError, match="unexpected CI workflow"): + evidence.validate_gate(gate, run, jobs, head="a" * 40, base=None, pr=None) + + +def test_pr_evidence_cannot_use_the_post_workflow() -> None: + gate, run, jobs = green_gate() + run["path"] = ".github/workflows/ci-post.yml" + with pytest.raises(ValueError, match="unexpected CI workflow"): + evidence.validate_gate(gate, run, jobs, head="a" * 40, base="b" * 40, pr=7) + + +def test_recovery_queries_the_landed_post_workflow(baseline, monkeypatch) -> None: + pr = { + "number": 7, + "merged_at": "2026-10-01T00:00:00Z", + "merge_commit_sha": "a" * 40, + "head": {"sha": "b" * 40}, + "base": {"ref": "main", "sha": "c" * 40}, + "body": automation.body({"version": "0.3.0", "base": "c" * 40}), + } + monkeypatch.setattr(publishing, "stored_receipt", lambda *args: None) + + def inventory(repo, endpoint): + assert repo == "owner/data" + assert endpoint == "pulls?state=closed&base=main&sort=updated&direction=desc" + return [pr] + + def api(repo, endpoint): + assert repo == "owner/data" + assert endpoint == ( + "actions/workflows/ci-post.yml/runs?event=push&per_page=100&head_sha=" + "a" * 40 + ) + return {"workflow_runs": []} + + monkeypatch.setattr(publishing, "inventory", inventory) + monkeypatch.setattr(publishing, "api", api) + report = {"pr": None, "state": "queued"} + publishing.recover_applied("owner/data", baseline, report) + assert report["state"] == "testing" + assert report["pr"]["merge"] == "a" * 40 + + @pytest.mark.parametrize("job_name", [*evidence.JOBS, "release/ready"]) @pytest.mark.parametrize("result", ["failure", "cancelled", "skipped", "missing"]) def test_green_workflow_cannot_hide_a_missing_or_failed_gate(job_name, result) -> None: @@ -399,6 +447,9 @@ def evidence_inputs(tmp_path: Path, monkeypatch): workflow = root / ".github" / "workflows" / "ci.yml" workflow.parent.mkdir(parents=True) workflow.write_bytes((ROOT / ".github" / "workflows" / "ci.yml").read_bytes()) + (workflow.parent / "ci-post.yml").write_bytes( + (ROOT / ".github/workflows/ci-post.yml").read_bytes() + ) hooks = tmp_path / "empty-hooks" hooks.mkdir() subprocess.run( @@ -524,12 +575,19 @@ def test_create_evidence_rejects_continue_on_error(evidence_inputs, workflow) -> evidence.create_evidence(root, directory, needs) -def test_reviewed_ci_workflow_pin_matches_the_file() -> None: +@pytest.mark.parametrize( + "filename,pin", + [ + ("ci.yml", "REVIEWED_CI_WORKFLOW_SHA256"), + ("ci-post.yml", "REVIEWED_CI_POST_WORKFLOW_SHA256"), + ], +) +def test_reviewed_ci_workflow_pin_matches_the_file(filename: str, pin: str) -> None: assert ( - evidence.sha256((ROOT / ".github" / "workflows" / "ci.yml").read_bytes()) - == evidence.REVIEWED_CI_WORKFLOW_SHA256 + evidence.sha256((ROOT / ".github/workflows" / filename).read_bytes()) + == getattr(evidence, pin) ), ( - "ci.yml changed: update REVIEWED_CI_WORKFLOW_SHA256 after reviewing that " + f"{filename} changed: update {pin} after reviewing that " 'needs..result == "success" still proves every matrix leg succeeded' ) @@ -1118,6 +1176,7 @@ def assert_pull_request_filter_policy(workflow: dict[str, Any], name: str) -> No assert pull_request.get("paths") == [ "native/feed/**", ".github/workflows/native-feed.yml", + ".github/workflows/native-feed-post.yml", ] else: assert name in {"ci.yml", "docs.yml"}