From b34bdad98f161dfd43921c93c88fa0c26317b685 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 28 Oct 2026 11:00:42 +0800 Subject: [PATCH 1/4] Split landed CI and native builds onto GKE pods ci.yml and native-feed.yml now trigger on pull_request only and keep their hosted jobs, displayed names and required checks unchanged. The new ci-post.yml and native-feed-post.yml run on push to main and workflow_dispatch: authz and a main-ref guard stay hosted, the proof jobs take bare scalar GKE labels sized from their needs, and one hosted aggregate keeps the name `check`. database-e2e and the macOS leg stay hosted. docs.yml is unchanged because the Pages actions have no verified immutable pins. The release evidence reader and the recovery query now follow the landed run to ci-post.yml, and pin the exact bytes of both workflow files. Replaced documentation sentences (docs/releasing.md): - "The App's merge triggers ordinary main CI." is now "The App's merge triggers ci-post.yml on main." - "The landed main CI still authorizes the exact wheel/sdist bytes used by the existing publisher." is now "The landed ci-post.yml run authorizes the exact wheel/sdist bytes used by the existing publisher." Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci-post.yml | 450 ++++++++++++++++++ .github/workflows/ci.yml | 13 +- .github/workflows/native-feed-post.yml | 224 +++++++++ .github/workflows/native-feed.yml | 12 +- docs/releasing.md | 4 +- scripts/release_chain/evidence.py | 32 +- scripts/release_chain/publishing.py | 3 +- .../ci_canon/canon-workflow-lint-gke-job.yml | 57 +++ tests/test_post_merge_workflows.py | 329 +++++++++++++ tests/test_pr_tier_workflows.py | 2 +- tests/test_release_adapter.py | 67 ++- 11 files changed, 1154 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/ci-post.yml create mode 100644 .github/workflows/native-feed-post.yml create mode 100644 tests/fixtures/ci_canon/canon-workflow-lint-gke-job.yml create mode 100644 tests/test_post_merge_workflows.py diff --git a/.github/workflows/ci-post.yml b/.github/workflows/ci-post.yml new file mode 100644 index 0000000..0f56293 --- /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: gke-4cpu-8gb-amd64 + env: + PF_CI_LABELS: '["gke-4cpu-8gb-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' + - 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: gke-4cpu-8gb-amd64 + env: + PF_CI_LABELS: '["gke-4cpu-8gb-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' + 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..614d657 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 = ( + "8f2ef289f3c0ee51c5dce20461575694cbd919f4995236c99d871f8708421339" +) 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..febc5f7 --- /dev/null +++ b/tests/test_post_merge_workflows.py @@ -0,0 +1,329 @@ +"""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": "gke-4cpu-8gb-amd64", + "database-e2e": "ubuntu-24.04", + "contract": "gke-2cpu-4gb-amd64", + "distribution": "gke-4cpu-8gb-amd64", + "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["distribution"]["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="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"} From 87f759fa376444b65d1d3cde12dcfe22b378570c Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 28 Oct 2026 12:16:10 +0800 Subject: [PATCH 2/4] tests: satisfy ruff in the post-merge workflow tests Wrap the over-long upload-artifact pin line (E501) and write the ci-post.yml match pattern as a raw escaped string (RUF043). No behaviour change. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_post_merge_workflows.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_post_merge_workflows.py b/tests/test_post_merge_workflows.py index febc5f7..1fdf4f1 100644 --- a/tests/test_post_merge_workflows.py +++ b/tests/test_post_merge_workflows.py @@ -38,7 +38,9 @@ PINS = { "actions/checkout@v7": "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", "actions/setup-python@v6": "actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1", - "actions/upload-artifact@v5": "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "actions/upload-artifact@v5": ( + "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" + ), } @@ -306,7 +308,7 @@ def test_post_workflow_bytes_require_independent_review(tmp_path: Path) -> None: 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="ci-post.yml changed"): + with pytest.raises(ValueError, match=r"ci-post\.yml changed"): evidence.require_no_continue_on_error(root) From a7cadf086eb2fdf7fb41ec6ad9c65d21aa61a623 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 28 Oct 2026 12:35:39 +0800 Subject: [PATCH 3/4] ci-post: keep the Docker runtime and Distribution jobs on hosted runners No run before merge shows a working Docker daemon on a gke-4cpu-8gb-amd64 pod, and a red main on this repository blocks the next release upload. Both jobs go back to ubuntu-24.04 (with PF_CI_LABELS to match); their steps, including the docker info check, are unchanged. The reviewed ci-post.yml digest in the release evidence reader and the route table in the post-merge workflow test follow. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci-post.yml | 8 ++++---- scripts/release_chain/evidence.py | 2 +- tests/test_post_merge_workflows.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci-post.yml b/.github/workflows/ci-post.yml index 0f56293..925dd1e 100644 --- a/.github/workflows/ci-post.yml +++ b/.github/workflows/ci-post.yml @@ -183,9 +183,9 @@ jobs: docker: name: Docker runtime needs: [authz, main-ref] - runs-on: gke-4cpu-8gb-amd64 + runs-on: ubuntu-24.04 env: - PF_CI_LABELS: '["gke-4cpu-8gb-amd64"]' + PF_CI_LABELS: '["ubuntu-24.04"]' steps: - name: authz must have passed if: needs.authz.result != 'success' @@ -298,9 +298,9 @@ jobs: distribution: name: Distribution needs: [authz, main-ref] - runs-on: gke-4cpu-8gb-amd64 + runs-on: ubuntu-24.04 env: - PF_CI_LABELS: '["gke-4cpu-8gb-amd64"]' + PF_CI_LABELS: '["ubuntu-24.04"]' steps: - name: authz must have passed if: needs.authz.result != 'success' diff --git a/scripts/release_chain/evidence.py b/scripts/release_chain/evidence.py index 614d657..d876223 100644 --- a/scripts/release_chain/evidence.py +++ b/scripts/release_chain/evidence.py @@ -18,7 +18,7 @@ # every leg succeeded (2026-10-23, VP-145 review P0: a lexical test alone misses escaped YAML keys). REVIEWED_CI_WORKFLOW_SHA256 = "d62bd2fa2c574d336654df1a18988eb8692396cf75d21dc9f6df0d59735f3225" REVIEWED_CI_POST_WORKFLOW_SHA256 = ( - "8f2ef289f3c0ee51c5dce20461575694cbd919f4995236c99d871f8708421339" + "0d482244dd46ed3e4ec7ac6e5297521c81106922a93c24fde01ed1e87d2fdd5d" ) JOBS = ( diff --git a/tests/test_post_merge_workflows.py b/tests/test_post_merge_workflows.py index 1fdf4f1..e650227 100644 --- a/tests/test_post_merge_workflows.py +++ b/tests/test_post_merge_workflows.py @@ -21,10 +21,10 @@ ROUTES = { "workflow-lint": "gke-2cpu-4gb-amd64", "test": "gke-4cpu-8gb-amd64", - "docker": "gke-4cpu-8gb-amd64", + "docker": "ubuntu-24.04", "database-e2e": "ubuntu-24.04", "contract": "gke-2cpu-4gb-amd64", - "distribution": "gke-4cpu-8gb-amd64", + "distribution": "ubuntu-24.04", "docs-tests": "gke-2cpu-4gb-amd64", "ready": "gke-2cpu-4gb-amd64", } From 4f27b6d59290685d0280d499fa06c1a124b098e9 Mon Sep 17 00:00:00 2001 From: luisleo526 Date: Wed, 28 Oct 2026 12:39:58 +0800 Subject: [PATCH 4/4] tests: point the runner mutation control at a job that still runs on a pod The runner row of test_post_ci_control_rejects_reverting_a_proof set the Distribution job to ubuntu-24.04, which is now its correct runner, so the mutation no longer changed anything and the control did not raise. It now sets the ready job, which must stay on a pod, to the hosted runner. Co-Authored-By: Claude Sonnet 5.5 --- tests/test_post_merge_workflows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_post_merge_workflows.py b/tests/test_post_merge_workflows.py index e650227..107892e 100644 --- a/tests/test_post_merge_workflows.py +++ b/tests/test_post_merge_workflows.py @@ -181,7 +181,7 @@ def test_post_ci_control_rejects_reverting_a_proof(fault: str) -> None: elif fault == "gate": jobs["check"]["if"] = "${{ !cancelled() }}" else: - jobs["distribution"]["runs-on"] = "ubuntu-24.04" + jobs["ready"]["runs-on"] = "ubuntu-24.04" with pytest.raises(AssertionError): assert_post_ci(changed)