From e11293851bda6589da50176e042c3c7e19a6acac Mon Sep 17 00:00:00 2001 From: mrjf Date: Thu, 11 Jun 2026 09:16:05 -0700 Subject: [PATCH 1/2] harden(go-migration): require upstream APM freshness --- .crane/scripts/score.go | 30 ++ .github/workflows/migration-ci.yml | 66 ++- .github/workflows/upstream-apm-sync.yml | 137 ++++++ cmd/apm/CUTOVER.md | 45 +- .../go_cutover/python_test_coverage.json | 27 ++ scripts/ci/upstream_apm_contracts.py | 458 ++++++++++++++++++ tests/parity/upstream_contract_coverage.yml | 13 + tests/unit/test_crane_score.py | 51 ++ tests/unit/test_migration_ci_workflow.py | 4 + tests/unit/test_upstream_apm_contracts.py | 190 ++++++++ tests/unit/test_upstream_apm_sync_workflow.py | 36 ++ 11 files changed, 1040 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/upstream-apm-sync.yml create mode 100644 scripts/ci/upstream_apm_contracts.py create mode 100644 tests/parity/upstream_contract_coverage.yml create mode 100644 tests/unit/test_upstream_apm_contracts.py create mode 100644 tests/unit/test_upstream_apm_sync_workflow.py diff --git a/.crane/scripts/score.go b/.crane/scripts/score.go index 4c1fbacc..70ebe370 100644 --- a/.crane/scripts/score.go +++ b/.crane/scripts/score.go @@ -64,6 +64,8 @@ type CutoverGates struct { FunctionalContracts float64 `json:"functional_contracts"` StateDiffContracts float64 `json:"state_diff_contracts"` PythonBehaviorContracts float64 `json:"python_behavior_contracts"` + UpstreamFreshness string `json:"upstream_freshness"` + UpstreamContracts float64 `json:"upstream_contracts"` GoldenFixtureCorpus string `json:"golden_fixture_corpus"` AllGoGoldenTests string `json:"all_go_golden_tests"` NoPythonRuntime string `json:"no_python_runtime_dependency"` @@ -104,6 +106,8 @@ type Score struct { PythonTestsPassing bool `json:"python_tests_passing"` GoTestsPassing bool `json:"go_tests_passing"` BenchmarksPassing bool `json:"benchmarks_passing"` + UpstreamFreshness bool `json:"upstream_freshness"` + UpstreamContracts float64 `json:"upstream_contracts"` GoldenFixtureCorpus bool `json:"golden_fixture_corpus"` AllGoGoldenTests bool `json:"all_go_golden_tests"` NoPythonRuntime bool `json:"no_python_runtime_dependency"` @@ -152,6 +156,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { functional := RatioGate{} stateDiff := RatioGate{} behaviorContracts := RatioGate{} + upstreamFreshness := BoolGate{} + upstreamContracts := RatioGate{} goldenFixtureCorpus := BoolGate{} allGoGoldenTests := BoolGate{} noPythonRuntime := BoolGate{} @@ -172,6 +178,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { &functional, &stateDiff, &behaviorContracts, + &upstreamFreshness, + &upstreamContracts, &goldenFixtureCorpus, &allGoGoldenTests, &noPythonRuntime, @@ -199,6 +207,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { &functional, &stateDiff, &behaviorContracts, + &upstreamFreshness, + &upstreamContracts, &goldenFixtureCorpus, &allGoGoldenTests, &noPythonRuntime, @@ -283,6 +293,12 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { if !behaviorContracts.Seen { behaviorContracts = missingRatioGate() } + if !upstreamFreshness.Seen { + upstreamFreshness = BoolGate{Seen: true, Passed: false} + } + if !upstreamContracts.Seen { + upstreamContracts = missingRatioGate() + } if !pythonTests.Seen { pythonTests = BoolGate{Seen: true, Passed: testPassed(passed, failed, "TestParityCompletionPythonSuite")} } @@ -302,6 +318,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { FunctionalContracts: functional.Percent(), StateDiffContracts: stateDiff.Percent(), PythonBehaviorContracts: behaviorContracts.Percent(), + UpstreamFreshness: passFail(upstreamFreshness.OK()), + UpstreamContracts: upstreamContracts.Percent(), GoldenFixtureCorpus: passFail(goldenFixtureCorpus.OK()), AllGoGoldenTests: passFail(allGoGoldenTests.OK()), NoPythonRuntime: passFail(noPythonRuntime.OK()), @@ -328,6 +346,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { gates.FunctionalContracts == 1.0 && gates.StateDiffContracts == 1.0 && gates.PythonBehaviorContracts == 1.0 && + gates.UpstreamFreshness == "pass" && + gates.UpstreamContracts == 1.0 && gates.GoldenFixtureCorpus == "pass" && gates.AllGoGoldenTests == "pass" && gates.NoPythonRuntime == "pass" && @@ -372,6 +392,8 @@ func computeScore(input scanInput, getenv getenvFunc) (Score, error) { PythonTestsPassing: gates.PythonTests == "pass", GoTestsPassing: gates.GoTests == "pass", BenchmarksPassing: gates.Benchmarks == "pass", + UpstreamFreshness: gates.UpstreamFreshness == "pass", + UpstreamContracts: gates.UpstreamContracts, GoldenFixtureCorpus: gates.GoldenFixtureCorpus == "pass", AllGoGoldenTests: gates.AllGoGoldenTests == "pass", NoPythonRuntime: gates.NoPythonRuntime == "pass", @@ -405,6 +427,8 @@ func applyGateEvent( functional *RatioGate, stateDiff *RatioGate, behaviorContracts *RatioGate, + upstreamFreshness *BoolGate, + upstreamContracts *RatioGate, goldenFixtureCorpus *BoolGate, allGoGoldenTests *BoolGate, noPythonRuntime *BoolGate, @@ -427,6 +451,10 @@ func applyGateEvent( *stateDiff = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total} case "python_behavior_contracts": *behaviorContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total} + case "upstream_freshness": + *upstreamFreshness = BoolGate{Seen: true, Passed: gate.Passed} + case "upstream_contracts": + *upstreamContracts = RatioGate{Seen: true, Passing: gate.Passing, Total: gate.Total} case "golden_fixture_corpus": *goldenFixtureCorpus = BoolGate{Seen: true, Passed: gate.Passed} case "all_go_golden_tests": @@ -485,6 +513,8 @@ func gateResults(gates CutoverGates) []GateResult { {Name: "functional_contracts", Passing: gates.FunctionalContracts == 1.0}, {Name: "state_diff_contracts", Passing: gates.StateDiffContracts == 1.0}, {Name: "python_behavior_contracts", Passing: gates.PythonBehaviorContracts == 1.0}, + {Name: "upstream_freshness", Passing: gates.UpstreamFreshness == "pass"}, + {Name: "upstream_contracts", Passing: gates.UpstreamContracts == 1.0}, {Name: "golden_fixture_corpus", Passing: gates.GoldenFixtureCorpus == "pass"}, {Name: "all_go_golden_tests", Passing: gates.AllGoGoldenTests == "pass"}, {Name: "no_python_runtime_dependency", Passing: gates.NoPythonRuntime == "pass"}, diff --git a/.github/workflows/migration-ci.yml b/.github/workflows/migration-ci.yml index d5958e05..94ba3646 100644 --- a/.github/workflows/migration-ci.yml +++ b/.github/workflows/migration-ci.yml @@ -64,6 +64,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - uses: actions/setup-python@v5 with: @@ -92,12 +94,16 @@ jobs: - name: Run CLI-agnostic Python behavior tests shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE_COMPLETION_INPUT: ${{ inputs.enforce_completion }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | go build -o "$RUNNER_TEMP/apm-go" ./cmd/apm enforce_behavior_contracts=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.enforce_completion == true }}" = "true" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${ENFORCE_COMPLETION_INPUT:-false}" = "true" ]; then enforce_behavior_contracts=true - elif [ "${{ github.event_name }}" = "pull_request" ] && [[ "${{ github.event.pull_request.head.ref }}" == crane/* ]]; then + elif [ "$EVENT_NAME" = "pull_request" ] && [[ "${HEAD_REF:-}" == crane/* ]]; then enforce_behavior_contracts=true fi if [ "$enforce_behavior_contracts" = "true" ]; then @@ -113,11 +119,15 @@ jobs: - name: Run Go parity tests shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE_COMPLETION_INPUT: ${{ inputs.enforce_completion }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | enforce_completion=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.enforce_completion == true }}" = "true" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${ENFORCE_COMPLETION_INPUT:-false}" = "true" ]; then enforce_completion=true - elif [ "${{ github.event_name }}" = "pull_request" ] && [[ "${{ github.event.pull_request.head.ref }}" == crane/* ]]; then + elif [ "$EVENT_NAME" = "pull_request" ] && [[ "${HEAD_REF:-}" == crane/* ]]; then enforce_completion=true fi @@ -136,10 +146,10 @@ jobs: shell: bash run: | set +e - APM_PYTHON_BIN= \ - APM_PYTHON_CONTRACT_INVENTORY= \ - PYTHONPATH= \ - VIRTUAL_ENV= \ + APM_PYTHON_BIN="" \ + APM_PYTHON_CONTRACT_INVENTORY="" \ + PYTHONPATH="" \ + VIRTUAL_ENV="" \ go test -json ./cmd/apm -run '^TestGoCutover' \ | tee "$RUNNER_TEMP/go-cutover-events.json" status=${PIPESTATUS[0]} @@ -147,6 +157,32 @@ jobs: cat "$RUNNER_TEMP/go-cutover-events.json" >> "$RUNNER_TEMP/go-test-events.json" echo "GO_CUTOVER_STATUS=$status" >> "$GITHUB_ENV" + - name: Check upstream APM contract coverage + shell: bash + run: | + git remote add upstream https://github.com/microsoft/apm.git 2>/dev/null || \ + git remote set-url upstream https://github.com/microsoft/apm.git + git fetch upstream main --prune + + upstream_args=( + --upstream-ref upstream/main + --head-ref HEAD + --coverage tests/parity/upstream_contract_coverage.yml + --summary "$RUNNER_TEMP/upstream-apm-contracts.md" + ) + if [ "${MIGRATION_COMPLETION_ENFORCED:-false}" = "true" ]; then + upstream_args+=(--enforce) + fi + + set +e + uv run python scripts/ci/upstream_apm_contracts.py check \ + "${upstream_args[@]}" \ + | tee "$RUNNER_TEMP/upstream-apm-contracts.txt" + status=${PIPESTATUS[0]} + set -e + cat "$RUNNER_TEMP/upstream-apm-contracts.txt" >> "$RUNNER_TEMP/go-test-events.json" + echo "UPSTREAM_APM_STATUS=$status" >> "$GITHUB_ENV" + - name: Compute migration score run: | go run .crane/scripts/score.go < "$RUNNER_TEMP/go-test-events.json" | tee "$RUNNER_TEMP/migration-score.json" @@ -191,6 +227,7 @@ jobs: test "${PYTHON_CLI_CONTRACT_STATUS:-1}" = "0" test "${GO_TEST_STATUS:-1}" = "0" test "${GO_CUTOVER_STATUS:-1}" = "0" + test "${UPSTREAM_APM_STATUS:-1}" = "0" else if [ "${PYTHON_CLI_CONTRACT_STATUS:-1}" != "0" ]; then echo "::notice::Python behavior contract tests are incomplete in collection mode." @@ -201,6 +238,9 @@ jobs: if [ "${GO_CUTOVER_STATUS:-1}" != "0" ]; then echo "::notice::Go-only cutover gate is incomplete in collection mode." fi + if [ "${UPSTREAM_APM_STATUS:-1}" != "0" ]; then + echo "::notice::Upstream APM freshness/contract coverage is incomplete in collection mode." + fi fi - name: Upload parity evidence @@ -215,6 +255,8 @@ jobs: ${{ runner.temp }}/python-behavior-contracts.json ${{ runner.temp }}/python-contract-coverage.md ${{ runner.temp }}/python-cli-contract-tests.txt + ${{ runner.temp }}/upstream-apm-contracts.txt + ${{ runner.temp }}/upstream-apm-contracts.md if-no-files-found: ignore retention-days: 14 @@ -247,11 +289,15 @@ jobs: - name: Run Python-vs-Go CLI benchmark shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + ENFORCE_COMPLETION_INPUT: ${{ inputs.enforce_completion }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} run: | enforce_completion=false - if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.enforce_completion == true }}" = "true" ]; then + if [ "$EVENT_NAME" = "workflow_dispatch" ] && [ "${ENFORCE_COMPLETION_INPUT:-false}" = "true" ]; then enforce_completion=true - elif [ "${{ github.event_name }}" = "pull_request" ] && [[ "${{ github.event.pull_request.head.ref }}" == crane/* ]]; then + elif [ "$EVENT_NAME" = "pull_request" ] && [[ "${HEAD_REF:-}" == crane/* ]]; then enforce_completion=true fi diff --git a/.github/workflows/upstream-apm-sync.yml b/.github/workflows/upstream-apm-sync.yml new file mode 100644 index 00000000..7848499e --- /dev/null +++ b/.github/workflows/upstream-apm-sync.yml @@ -0,0 +1,137 @@ +name: Upstream APM Sync + +on: + schedule: + - cron: "17 * * * *" + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + issues: write + +concurrency: + group: upstream-apm-sync + cancel-in-progress: false + +env: + UPSTREAM_REPO: https://github.com/microsoft/apm.git + UPSTREAM_BRANCH: main + SYNC_BRANCH: automation/upstream-microsoft-apm-main + +jobs: + sync: + name: Sync microsoft/apm main + runs-on: ubuntu-24.04 + steps: + - name: Check out main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Fetch upstream + run: | + git remote add upstream "$UPSTREAM_REPO" 2>/dev/null || \ + git remote set-url upstream "$UPSTREAM_REPO" + git fetch upstream "$UPSTREAM_BRANCH" --prune + git fetch origin main --prune + + - name: Merge upstream into sync branch + id: merge + shell: bash + run: | + upstream_ref="upstream/${UPSTREAM_BRANCH}" + upstream_sha="$(git rev-parse "$upstream_ref")" + origin_sha="$(git rev-parse origin/main)" + echo "upstream_sha=$upstream_sha" >> "$GITHUB_OUTPUT" + echo "origin_sha=$origin_sha" >> "$GITHUB_OUTPUT" + + if git merge-base --is-ancestor "$upstream_ref" origin/main; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "origin/main already contains ${upstream_ref} (${upstream_sha})." + exit 0 + fi + + git switch --force-create "$SYNC_BRANCH" origin/main + if ! git merge --no-ff --no-edit "$upstream_ref"; then + git status --short + echo "::error::Automatic upstream merge conflicted. Resolve manually by merging ${upstream_ref} into main." + exit 1 + fi + git push --force-with-lease origin "$SYNC_BRANCH" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Create or update sync PR + if: steps.merge.outputs.changed == 'true' + id: pr + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + body="$RUNNER_TEMP/upstream-sync-pr.md" + cat > "$body" <> "$GITHUB_OUTPUT" + gh pr view "$pr_number" --json url --jq .url + + - name: Request merge-commit auto-merge + if: steps.merge.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh pr merge "${{ steps.pr.outputs.number }}" --auto --merge --delete-branch; then + echo "Auto-merge requested for upstream sync PR #${{ steps.pr.outputs.number }}." + else + echo "::warning::Could not enable auto-merge. PR #${{ steps.pr.outputs.number }} is ready for maintainer review/merge." + fi + + - name: Summarize + if: always() + run: | + { + echo "## Upstream APM Sync" + echo + echo "- Upstream: \`${UPSTREAM_REPO}\`" + echo "- Branch: \`${UPSTREAM_BRANCH}\`" + echo "- Sync branch: \`${SYNC_BRANCH}\`" + echo "- Changed: \`${{ steps.merge.outputs.changed || 'unknown' }}\`" + echo "- Upstream SHA: \`${{ steps.merge.outputs.upstream_sha || 'unknown' }}\`" + echo "- Origin SHA: \`${{ steps.merge.outputs.origin_sha || 'unknown' }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/cmd/apm/CUTOVER.md b/cmd/apm/CUTOVER.md index aa772e66..5697342a 100644 --- a/cmd/apm/CUTOVER.md +++ b/cmd/apm/CUTOVER.md @@ -26,6 +26,8 @@ Gate summary: | functional_contracts | required | | state_diff_contracts | required | | python_behavior_contracts | required; no obsolete or help-only mappings | +| upstream_freshness | required; `HEAD` must contain the reviewed `microsoft/apm@main` SHA | +| upstream_contracts | required; every upstream Python behavior delta must map to existing Go tests | | golden_fixture_corpus | required | | all_go_golden_tests | required | | no_python_runtime_dependency | required | @@ -52,8 +54,10 @@ The output must show `"migration_score": 1` and `"cutover_ready": true`. Every completion criterion must be backed by real command execution. The scorer does not infer completion from test names for `surface`, `help`, -`option_parity`, `functional`, `state_diff`, `python_behavior_contracts`, or -`benchmarks`; each one must emit an explicit ratio gate. +`option_parity`, `functional`, `state_diff`, `python_behavior_contracts`, +`upstream_contracts`, or `benchmarks`; each ratio criterion must emit an +explicit ratio gate. The `upstream_freshness` boolean gate must also pass before +completion can be claimed. Crane must run `APM_PYTHON_BIN= go test ./cmd/apm -run TestGoCutover -json`. These fixture-backed tests execute the built Go `apm` binary in temporary @@ -64,6 +68,8 @@ directly: {"crane":"gate","name":"functional","passing":N,"total":N} {"crane":"gate","name":"state_diff","passing":N,"total":N} {"crane":"gate","name":"python_behavior_contracts","passing":N,"total":N} +{"crane":"gate","name":"upstream_freshness","passed":true} +{"crane":"gate","name":"upstream_contracts","passing":N,"total":N} {"crane":"gate","name":"golden_fixture_corpus","passed":true} {"crane":"gate","name":"all_go_golden_tests","passed":true} {"crane":"gate","name":"no_python_runtime_dependency","passed":true} @@ -117,6 +123,28 @@ benchmark fixture coverage before Crane can claim it moved the migration forward. Shims, dry-runs, mocks, and help-only assertions do not count as command completion. +## Upstream Freshness Criteria + +The migration is incomplete if this repository is stale relative to upstream +`microsoft/apm@main`. The scheduled `Upstream APM Sync` workflow fetches +`microsoft/apm`, creates or updates an upstream merge PR, and requests +merge-commit auto-merge so upstream history remains reachable. + +After each upstream merge, reviewers must inspect the upstream Python diff and +advance `tests/parity/upstream_contract_coverage.yml` with a reviewed range from +the previous upstream SHA to the new upstream SHA. Every changed public Python +source contract under `src/apm_cli/` and every changed Python test under +`tests/` must map to one or more existing Go tests. The checker emits: + +```json +{"crane":"gate","name":"upstream_freshness","passed":true} +{"crane":"gate","name":"upstream_contracts","passing":N,"total":N} +``` + +Both gates are deletion-grade completion gates. A stale upstream SHA, a missing +reviewed range, a missing Go test mapping, or a stale Go test name blocks +`migration_score = 1.0`. + ## Cutover Trigger Conditions The Go binary becomes the shipped `apm` command when ALL of the following @@ -138,18 +166,21 @@ are true: paths while the Python reference is still available 6. Migration benchmarks pass real fixture-backed command workloads and emit a passing counted `benchmarks` gate -7. The final Python-reference parity run has been frozen into a committed, +7. `HEAD` contains the current reviewed `microsoft/apm@main` SHA, and every + upstream Python behavior delta since the upstream baseline has reviewed Go + test coverage in `tests/parity/upstream_contract_coverage.yml` +8. The final Python-reference parity run has been frozen into a committed, versioned golden fixture corpus. The corpus must include CLI inventory, help and usage output, error output, exit codes, generated files, lockfiles, config files, managed-file manifests, deterministic cache/config layout, and audit artifacts for the full command matrix. -8. An all-Go golden replay passes against that corpus with no live Python +9. An all-Go golden replay passes against that corpus with no live Python oracle. The replay must build `cmd/apm` and compare only the Go binary against checked-in fixtures. -9. A no-Python-runtime check passes: `APM_PYTHON_BIN` is unset, the Python CLI +10. A no-Python-runtime check passes: `APM_PYTHON_BIN` is unset, the Python CLI is hidden or unavailable to the replay, and the golden replay still passes. -10. `go build ./cmd/apm` produces a single static binary -11. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`) +11. `go build ./cmd/apm` produces a single static binary +12. CI passes on the crane PR branch (`crane/crane-migration-python-to-go-full-apm-cli-rewrite`) ## Cutover Steps diff --git a/cmd/apm/testdata/go_cutover/python_test_coverage.json b/cmd/apm/testdata/go_cutover/python_test_coverage.json index 341efbe0..dca4fcf1 100644 --- a/cmd/apm/testdata/go_cutover/python_test_coverage.json +++ b/cmd/apm/testdata/go_cutover/python_test_coverage.json @@ -66219,6 +66219,9 @@ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_crane_score.py::test_crane_score_blocks_incomplete_upstream_contract_gate": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], "tests/unit/test_crane_score.py::test_crane_score_blocks_known_exceptions": [ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" @@ -66227,6 +66230,9 @@ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_crane_score.py::test_crane_score_blocks_stale_upstream_freshness_gate": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], "tests/unit/test_crane_score.py::test_crane_score_can_reach_one_with_all_deletion_grade_gates": [ "TestGoCutoverPythonTestConversionCoverage", "TestGoCutoverRealFunctionalAndStateDiffContracts" @@ -78936,6 +78942,27 @@ "tests/unit/test_update_policy.py::TestIsSelfUpdateEnabled::test_returns_true_when_enabled_is_true": [ "TestGoCutoverRealFunctionalAndStateDiffContracts" ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_accept_reviewed_range_with_existing_go_tests": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_fail_when_upstream_adds_unreviewed_python_behavior": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_pass_when_reviewed_sha_matches_head": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_contracts.py::test_upstream_contracts_require_chained_reviewed_range_when_sha_advances": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_sync_workflow.py::test_upstream_sync_workflow_fetches_and_merges_microsoft_apm": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_sync_workflow.py::test_upstream_sync_workflow_tells_reviewers_to_update_go_coverage": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], + "tests/unit/test_upstream_apm_sync_workflow.py::test_upstream_sync_workflow_uses_pr_auto_merge_not_squash": [ + "TestGoCutoverRealFunctionalAndStateDiffContracts" + ], "tests/unit/test_version.py::TestGetBuildSha::test_build_sha_constant_skips_git": [ "TestParityCLIVersionOutputFormat", "TestGoCutoverRealFunctionalAndStateDiffContracts" diff --git a/scripts/ci/upstream_apm_contracts.py b/scripts/ci/upstream_apm_contracts.py new file mode 100644 index 00000000..995feb28 --- /dev/null +++ b/scripts/ci/upstream_apm_contracts.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +"""Check upstream microsoft/apm freshness and Go migration coverage. + +The Go migration is not complete just because it matches the Python code that +was present when the migration started. It must also be current with upstream +``microsoft/apm`` and every upstream Python behavior delta must be reviewed and +mapped to real Go tests before Crane can claim completion. +""" + +from __future__ import annotations + +import argparse +import ast +import json +import os +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +GO_TEST_RE = re.compile(r"^func\s+(Test[A-Za-z0-9_]*)\s*\(") + + +@dataclass(frozen=True) +class Contract: + id: str + kind: str + + +@dataclass(frozen=True) +class Finding: + code: str + contract: str + message: str + + +@dataclass(frozen=True) +class CheckResult: + upstream_sha: str + reviewed_sha: str + freshness_ok: bool + contracts_passing: int + contracts_total: int + findings: list[Finding] + freshness_findings: list[str] + + +def _run_git( + root: Path, args: list[str], *, check: bool = True +) -> subprocess.CompletedProcess[str]: + return subprocess.run( # noqa: S603 - git args are fixed by callers in this CI checker. + ["git", *args], # noqa: S607 - git is expected on PATH in CI and local tests. + cwd=root, + text=True, + capture_output=True, + check=check, + ) + + +def _git_stdout(root: Path, args: list[str]) -> str: + return _run_git(root, args).stdout.strip() + + +def _rev_parse(root: Path, ref: str) -> str: + return _git_stdout(root, ["rev-parse", ref]) + + +def _has_object(root: Path, ref: str) -> bool: + return _run_git(root, ["cat-file", "-e", f"{ref}^{{commit}}"], check=False).returncode == 0 + + +def _is_ancestor(root: Path, ancestor: str, descendant: str) -> bool: + return ( + _run_git( + root, + ["merge-base", "--is-ancestor", ancestor, descendant], + check=False, + ).returncode + == 0 + ) + + +def _changed_python_files(root: Path, start: str, end: str) -> list[str]: + out = _git_stdout( + root, + [ + "diff", + "--name-only", + "--diff-filter=ACMR", + f"{start}..{end}", + "--", + "src/apm_cli", + "tests", + ], + ) + return sorted( + path + for path in out.splitlines() + if path.endswith(".py") and not path.startswith("tests/parity/") + ) + + +def _blob_text(root: Path, ref: str, path: str) -> str | None: + proc = _run_git(root, ["show", f"{ref}:{path}"], check=False) + if proc.returncode != 0: + return None + return proc.stdout + + +def _source_contracts(path: str, text: str) -> list[Contract]: + try: + tree = ast.parse(text, filename=path) + except SyntaxError: + return [] + contracts: list[Contract] = [] + for node in tree.body: + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + continue + if node.name.startswith("_") and node.name != "__init__": + continue + contracts.append(Contract(id=f"{path}::{node.name}", kind="source")) + return contracts + + +def _test_contracts(path: str, text: str) -> list[Contract]: + try: + tree = ast.parse(text, filename=path) + except SyntaxError: + return [] + contracts: list[Contract] = [] + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith( + "test_" + ): + contracts.append(Contract(id=f"{path}::{node.name}", kind="python_test")) + elif isinstance(node, ast.ClassDef) and node.name.startswith("Test"): + for item in node.body: + if isinstance( + item, (ast.FunctionDef, ast.AsyncFunctionDef) + ) and item.name.startswith("test_"): + contracts.append( + Contract(id=f"{path}::{node.name}::{item.name}", kind="python_test") + ) + return contracts + + +def changed_contracts(root: Path, start: str, end: str) -> list[Contract]: + contracts: list[Contract] = [] + for path in _changed_python_files(root, start, end): + text = _blob_text(root, end, path) + if text is None: + continue + if path.startswith("src/apm_cli/"): + contracts.extend(_source_contracts(path, text)) + elif path.startswith("tests/"): + contracts.extend(_test_contracts(path, text)) + unique = {contract.id: contract for contract in contracts} + return [unique[key] for key in sorted(unique)] + + +def discover_go_tests(root: Path) -> set[str]: + tests: set[str] = set() + for file in sorted((root / "cmd" / "apm").rglob("*_test.go")): + with file.open(encoding="utf-8") as fh: + for line in fh: + match = GO_TEST_RE.match(line.strip()) + if match: + tests.add(match.group(1)) + return tests + + +def _load_yaml(path: Path) -> dict[str, Any]: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + if not isinstance(data, dict): + raise ValueError(f"coverage manifest must be a mapping: {path}") + return data + + +def _go_tests_for(entry: object) -> list[str]: + if not isinstance(entry, dict): + return [] + tests = entry.get("go_tests") + if not isinstance(tests, list): + return [] + return [test for test in tests if isinstance(test, str) and test] + + +def _coverage_for_contract(range_entry: dict[str, Any], contract: Contract) -> object: + key = "source_contracts" if contract.kind == "source" else "python_tests" + entries = range_entry.get(key) + if not isinstance(entries, dict): + return None + return entries.get(contract.id) + + +def _validate_contracts( + *, + contracts: list[Contract], + range_entry: dict[str, Any], + go_tests: set[str], + findings: list[Finding], +) -> int: + passing = 0 + for contract in contracts: + entry = _coverage_for_contract(range_entry, contract) + mapped_tests = _go_tests_for(entry) + if not mapped_tests: + findings.append( + Finding( + "missing-upstream-go-tests", + contract.id, + "upstream Python contract lacks mapped Go tests", + ) + ) + continue + unknown = [test for test in mapped_tests if test not in go_tests] + if unknown: + findings.append( + Finding( + "unknown-upstream-go-test", + contract.id, + "mapped Go tests do not exist: " + ", ".join(sorted(unknown)), + ) + ) + continue + passing += 1 + return passing + + +def _range_chain( + coverage: dict[str, Any], + *, + baseline_sha: str, + reviewed_sha: str, +) -> tuple[list[dict[str, Any]], list[Finding]]: + ranges = coverage.get("reviewed_ranges") or [] + if not isinstance(ranges, list): + return [], [Finding("invalid-reviewed-ranges", "reviewed_ranges", "must be a list")] + + by_start: dict[str, dict[str, Any]] = {} + for index, entry in enumerate(ranges): + if not isinstance(entry, dict): + return [], [ + Finding("invalid-reviewed-range", f"reviewed_ranges[{index}]", "must be a mapping") + ] + start = entry.get("from") + end = entry.get("to") + if not isinstance(start, str) or not isinstance(end, str): + return [], [ + Finding( + "invalid-reviewed-range", + f"reviewed_ranges[{index}]", + "range must include string 'from' and 'to' SHAs", + ) + ] + if start in by_start: + return [], [Finding("duplicate-reviewed-range", start, "multiple ranges start here")] + by_start[start] = entry + + chain: list[dict[str, Any]] = [] + cursor = baseline_sha + seen: set[str] = set() + while cursor != reviewed_sha: + if cursor in seen: + return chain, [Finding("cycle-reviewed-range", cursor, "reviewed range chain loops")] + seen.add(cursor) + entry = by_start.get(cursor) + if entry is None: + return chain, [ + Finding( + "missing-reviewed-range", + f"{cursor}..{reviewed_sha}", + "reviewed_sha advanced without a chained reviewed_ranges entry", + ) + ] + chain.append(entry) + cursor = str(entry["to"]) + return chain, [] + + +def check_upstream_contracts( + *, + root: Path, + coverage_path: Path, + upstream_ref: str, + head_ref: str, +) -> CheckResult: + coverage = _load_yaml(coverage_path) + upstream = coverage.get("upstream") or {} + if not isinstance(upstream, dict): + raise ValueError("coverage manifest must contain upstream mapping") + + baseline_sha = upstream.get("baseline_sha") + reviewed_sha = upstream.get("reviewed_sha") + if not isinstance(baseline_sha, str) or not isinstance(reviewed_sha, str): + raise ValueError("upstream.baseline_sha and upstream.reviewed_sha are required") + + upstream_sha = _rev_parse(root, upstream_ref) + head_sha = _rev_parse(root, head_ref) + freshness_findings: list[str] = [] + if reviewed_sha != upstream_sha: + freshness_findings.append( + f"reviewed upstream SHA {reviewed_sha} does not match {upstream_ref} at {upstream_sha}" + ) + if not _has_object(root, reviewed_sha): + freshness_findings.append(f"reviewed upstream SHA is not present locally: {reviewed_sha}") + elif not _is_ancestor(root, reviewed_sha, head_sha): + freshness_findings.append(f"HEAD does not contain reviewed upstream SHA {reviewed_sha}") + if _has_object(root, upstream_sha) and not _is_ancestor(root, upstream_sha, head_sha): + freshness_findings.append(f"HEAD does not contain current upstream SHA {upstream_sha}") + + go_tests = discover_go_tests(root) + findings: list[Finding] = [] + passing = 0 + total = 0 + + chain, chain_findings = _range_chain( + coverage, + baseline_sha=baseline_sha, + reviewed_sha=reviewed_sha, + ) + findings.extend(chain_findings) + + for range_entry in chain: + start = str(range_entry["from"]) + end = str(range_entry["to"]) + contracts = changed_contracts(root, start, end) + total += len(contracts) + passing += _validate_contracts( + contracts=contracts, + range_entry=range_entry, + go_tests=go_tests, + findings=findings, + ) + + if _has_object(root, reviewed_sha): + pending_contracts = changed_contracts(root, reviewed_sha, upstream_sha) + total += len(pending_contracts) + passing += _validate_contracts( + contracts=pending_contracts, + range_entry={}, + go_tests=go_tests, + findings=findings, + ) + + if total == 0: + total = 1 + passing = 1 if not findings else 0 + + return CheckResult( + upstream_sha=upstream_sha, + reviewed_sha=reviewed_sha, + freshness_ok=not freshness_findings, + contracts_passing=passing, + contracts_total=total, + findings=findings, + freshness_findings=freshness_findings, + ) + + +def render_summary(result: CheckResult, *, limit: int = 80) -> str: + lines = [ + "# Upstream APM Contract Coverage", + "", + f"- Current upstream SHA: `{result.upstream_sha}`", + f"- Reviewed upstream SHA: `{result.reviewed_sha}`", + f"- Freshness: {'pass' if result.freshness_ok else 'fail'}", + f"- Contract coverage: {result.contracts_passing}/{result.contracts_total}", + "", + "## Freshness Findings", + "", + ] + if result.freshness_findings: + lines.extend(f"- {finding}" for finding in result.freshness_findings) + else: + lines.append("No freshness findings.") + + lines.extend(["", "## Contract Findings", ""]) + if result.findings: + for finding in result.findings[:limit]: + lines.append(f"- `{finding.code}` `{finding.contract}`: {finding.message}") + if len(result.findings) > limit: + lines.append(f"- ... {len(result.findings) - limit} more findings omitted") + else: + lines.append("No contract findings.") + return "\n".join(lines) + "\n" + + +def _emit_gates(result: CheckResult) -> None: + print( + json.dumps( + { + "crane": "gate", + "name": "upstream_freshness", + "passed": result.freshness_ok, + }, + sort_keys=True, + ) + ) + print( + json.dumps( + { + "crane": "gate", + "name": "upstream_contracts", + "passing": result.contracts_passing, + "total": result.contracts_total, + }, + sort_keys=True, + ) + ) + + +def cmd_check(args: argparse.Namespace) -> int: + result = check_upstream_contracts( + root=Path(args.root).resolve(), + coverage_path=Path(args.coverage), + upstream_ref=args.upstream_ref, + head_ref=args.head_ref, + ) + _emit_gates(result) + summary = render_summary(result) + print(summary) + if args.summary: + Path(args.summary).write_text(summary, encoding="utf-8") + if args.enforce and ( + not result.freshness_ok or result.contracts_passing != result.contracts_total + ): + return 1 + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + check = sub.add_parser("check", help="check upstream APM freshness and coverage") + check.add_argument("--root", default=str(ROOT), help="repository root") + check.add_argument( + "--coverage", + default=str(ROOT / "tests" / "parity" / "upstream_contract_coverage.yml"), + help="upstream contract coverage manifest", + ) + check.add_argument("--upstream-ref", default="upstream/main") + check.add_argument("--head-ref", default="HEAD") + check.add_argument("--summary", help="write markdown summary to path") + check.add_argument("--enforce", action="store_true", help="fail on stale or uncovered upstream") + check.set_defaults(func=cmd_check) + + args = parser.parse_args(argv) + os.chdir(Path(args.root).resolve()) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/parity/upstream_contract_coverage.yml b/tests/parity/upstream_contract_coverage.yml new file mode 100644 index 00000000..d6a6fcd3 --- /dev/null +++ b/tests/parity/upstream_contract_coverage.yml @@ -0,0 +1,13 @@ +schema_version: 1 +description: > + Upstream microsoft/apm freshness and Go migration coverage ledger. The Go + migration cannot be declared complete unless upstream.reviewed_sha matches + microsoft/apm@main, that SHA is contained in HEAD, and every reviewed range + from baseline_sha to reviewed_sha maps changed Python source/test contracts + to existing Go tests. +upstream: + repo: microsoft/apm + branch: main + baseline_sha: ccdafc451ae92d2c2beb5fdaf9a0311252ce5577 + reviewed_sha: ccdafc451ae92d2c2beb5fdaf9a0311252ce5577 +reviewed_ranges: [] diff --git a/tests/unit/test_crane_score.py b/tests/unit/test_crane_score.py index 243e205b..279de945 100644 --- a/tests/unit/test_crane_score.py +++ b/tests/unit/test_crane_score.py @@ -75,6 +75,8 @@ def _deletion_gates() -> list[str]: '{"crane":"gate","name":"functional","passing":1,"total":1}', '{"crane":"gate","name":"state_diff","passing":1,"total":1}', '{"crane":"gate","name":"python_behavior_contracts","passing":1,"total":1}', + '{"crane":"gate","name":"upstream_freshness","passed":true}', + '{"crane":"gate","name":"upstream_contracts","passing":1,"total":1}', '{"crane":"gate","name":"golden_fixture_corpus","passed":true}', '{"crane":"gate","name":"all_go_golden_tests","passed":true}', '{"crane":"gate","name":"no_python_runtime_dependency","passed":true}', @@ -187,6 +189,8 @@ def test_crane_score_can_reach_one_with_all_deletion_grade_gates() -> None: "functional_contracts": 1.0, "state_diff_contracts": 1.0, "python_behavior_contracts": 1.0, + "upstream_freshness": "pass", + "upstream_contracts": 1.0, "known_exceptions": 0, "golden_fixture_corpus": "pass", "all_go_golden_tests": "pass", @@ -226,6 +230,8 @@ def test_crane_score_can_reach_one_with_no_python_all_go_replay() -> None: '{"crane":"gate","name":"functional","passing":0,"total":1}', '{"crane":"gate","name":"state_diff","passing":0,"total":1}', '{"crane":"gate","name":"python_behavior_contracts","passing":0,"total":1}', + '{"crane":"gate","name":"upstream_freshness","passed":false}', + '{"crane":"gate","name":"upstream_contracts","passing":0,"total":1}', '{"crane":"gate","name":"golden_fixture_corpus","passed":false}', '{"crane":"gate","name":"all_go_golden_tests","passed":false}', '{"crane":"gate","name":"no_python_runtime_dependency","passed":false}', @@ -330,6 +336,8 @@ def test_crane_score_does_not_infer_completion_gates_from_test_names() -> None: assert gates["functional_contracts"]["passing"] is False assert gates["state_diff_contracts"]["passing"] is False assert gates["python_behavior_contracts"]["passing"] is False + assert gates["upstream_freshness"]["passing"] is False + assert gates["upstream_contracts"]["passing"] is False assert gates["benchmarks_pass"]["passing"] is False @@ -356,6 +364,49 @@ def test_crane_score_blocks_incomplete_behavior_contract_gate() -> None: assert gates["python_behavior_contracts"]["passing"] is False +def test_crane_score_blocks_stale_upstream_freshness_gate() -> None: + gates = [line for line in _deletion_gates() if json.loads(line)["name"] != "upstream_freshness"] + score = _run_score( + [ + *_parity_passes(293), + *_completion_gate_events(), + *gates, + '{"crane":"gate","name":"upstream_freshness","passed":false}', + _package_pass(), + ] + ) + gates = _gates(score) + + assert score["progress"] == 1.0 + assert score["migration_score"] < 1.0 + assert score["deletion_grade_ready"] is False + assert gates["upstream_freshness"]["passing"] is False + + +def test_crane_score_blocks_incomplete_upstream_contract_gate() -> None: + gates = [line for line in _deletion_gates() if json.loads(line)["name"] != "upstream_contracts"] + score = _run_score( + [ + *_parity_passes(293), + *_completion_gate_events(), + *gates, + _ratio_gate_output( + "TestParityCompletionUpstreamContracts", + "upstream_contracts", + 1, + 2, + ), + _package_pass(), + ] + ) + gates = _gates(score) + + assert score["progress"] == 1.0 + assert score["migration_score"] < 1.0 + assert score["deletion_grade_ready"] is False + assert gates["upstream_contracts"]["passing"] is False + + def test_crane_score_blocks_incomplete_real_functional_gate() -> None: gates = [line for line in _deletion_gates() if json.loads(line)["name"] != "functional"] score = _run_score( diff --git a/tests/unit/test_migration_ci_workflow.py b/tests/unit/test_migration_ci_workflow.py index 15a0286c..473b0227 100644 --- a/tests/unit/test_migration_ci_workflow.py +++ b/tests/unit/test_migration_ci_workflow.py @@ -17,6 +17,9 @@ def test_migration_ci_enforces_completion_for_crane_prs_and_explicit_manual_runs assert "MIGRATION_COMPLETION_ENFORCED=$enforce_completion" in text assert "APM_ENFORCE_COMPLETION_GATES=1" in text assert "APM_ENFORCE_PYTHON_BEHAVIOR_CONTRACTS=1" in text + assert "scripts/ci/upstream_apm_contracts.py check" in text + assert "--enforce" in text + assert "UPSTREAM_APM_STATUS" in text assert "--allow-obsolete-python-tests" in text assert "inputs.enforce_completion == true" in text assert 'github.event.pull_request.head.ref }}" == crane/*' in text @@ -30,3 +33,4 @@ def test_migration_ci_collects_incomplete_evidence_for_non_crane_prs() -> None: assert "Non-enforcing migration evidence run" in text assert "Python behavior contract tests are incomplete in collection mode." in text assert "Go parity tests are incomplete in collection mode." in text + assert "Upstream APM freshness/contract coverage is incomplete in collection mode." in text diff --git a/tests/unit/test_upstream_apm_contracts.py b/tests/unit/test_upstream_apm_contracts.py new file mode 100644 index 00000000..12b2c01d --- /dev/null +++ b/tests/unit/test_upstream_apm_contracts.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "scripts" / "ci")) + +from upstream_apm_contracts import check_upstream_contracts # noqa: E402 + + +def _git(repo: Path, *args: str) -> str: + return subprocess.run( + ["git", *args], + cwd=repo, + text=True, + capture_output=True, + check=True, + ).stdout.strip() + + +def _commit(repo: Path, message: str) -> str: + _git(repo, "add", ".") + _git(repo, "commit", "-m", message) + return _git(repo, "rev-parse", "HEAD") + + +@pytest.fixture() +def repo(tmp_path: Path) -> Path: + _git(tmp_path, "init") + _git(tmp_path, "config", "user.email", "test@example.com") + _git(tmp_path, "config", "user.name", "Test User") + (tmp_path / "src" / "apm_cli").mkdir(parents=True) + (tmp_path / "tests" / "unit").mkdir(parents=True) + (tmp_path / "cmd" / "apm").mkdir(parents=True) + (tmp_path / "src" / "apm_cli" / "__init__.py").write_text("", encoding="utf-8") + (tmp_path / "cmd" / "apm" / "real_behavior_test.go").write_text( + "package main\n\nfunc TestGoUpstreamBehavior(t *testing.T) {}\n", + encoding="utf-8", + ) + _commit(tmp_path, "base") + return tmp_path + + +def _write_manifest(repo: Path, data: dict[str, object]) -> Path: + path = repo / "coverage.yml" + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + return path + + +def test_upstream_contracts_pass_when_reviewed_sha_matches_head(repo: Path) -> None: + head = _git(repo, "rev-parse", "HEAD") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": head, "reviewed_sha": head}, + "reviewed_ranges": [], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=head, + head_ref=head, + ) + + assert result.freshness_ok is True + assert result.contracts_passing == result.contracts_total == 1 + assert result.findings == [] + + +def test_upstream_contracts_fail_when_upstream_adds_unreviewed_python_behavior( + repo: Path, +) -> None: + base = _git(repo, "rev-parse", "HEAD") + (repo / "src" / "apm_cli" / "new_feature.py").write_text( + "def kiro_target():\n return 'kiro'\n", + encoding="utf-8", + ) + (repo / "tests" / "unit" / "test_new_feature.py").write_text( + "def test_kiro_target():\n assert True\n", + encoding="utf-8", + ) + upstream = _commit(repo, "upstream behavior") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": base, "reviewed_sha": base}, + "reviewed_ranges": [], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=upstream, + head_ref=base, + ) + + assert result.freshness_ok is False + assert result.contracts_passing == 0 + assert result.contracts_total == 2 + assert {finding.code for finding in result.findings} == {"missing-upstream-go-tests"} + + +def test_upstream_contracts_require_chained_reviewed_range_when_sha_advances( + repo: Path, +) -> None: + base = _git(repo, "rev-parse", "HEAD") + (repo / "src" / "apm_cli" / "new_feature.py").write_text( + "def source_base():\n return 'marketplace'\n", + encoding="utf-8", + ) + upstream = _commit(repo, "upstream source") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": base, "reviewed_sha": upstream}, + "reviewed_ranges": [], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=upstream, + head_ref=upstream, + ) + + assert result.freshness_ok is True + assert [finding.code for finding in result.findings] == ["missing-reviewed-range"] + assert result.contracts_passing == 0 + + +def test_upstream_contracts_accept_reviewed_range_with_existing_go_tests(repo: Path) -> None: + base = _git(repo, "rev-parse", "HEAD") + (repo / "src" / "apm_cli" / "new_feature.py").write_text( + "def optional_registry_inputs():\n return True\n", + encoding="utf-8", + ) + (repo / "tests" / "unit" / "test_new_feature.py").write_text( + "class TestOptionalRegistryInputs:\n" + " def test_preserves_optional_input(self):\n" + " assert True\n", + encoding="utf-8", + ) + upstream = _commit(repo, "upstream contracts") + manifest = _write_manifest( + repo, + { + "schema_version": 1, + "upstream": {"baseline_sha": base, "reviewed_sha": upstream}, + "reviewed_ranges": [ + { + "from": base, + "to": upstream, + "source_contracts": { + "src/apm_cli/new_feature.py::optional_registry_inputs": { + "go_tests": ["TestGoUpstreamBehavior"] + } + }, + "python_tests": { + ( + "tests/unit/test_new_feature.py::" + "TestOptionalRegistryInputs::test_preserves_optional_input" + ): {"go_tests": ["TestGoUpstreamBehavior"]} + }, + } + ], + }, + ) + + result = check_upstream_contracts( + root=repo, + coverage_path=manifest, + upstream_ref=upstream, + head_ref=upstream, + ) + + assert result.freshness_ok is True + assert result.contracts_passing == result.contracts_total == 2 + assert result.findings == [] diff --git a/tests/unit/test_upstream_apm_sync_workflow.py b/tests/unit/test_upstream_apm_sync_workflow.py new file mode 100644 index 00000000..4dde5626 --- /dev/null +++ b/tests/unit/test_upstream_apm_sync_workflow.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github" / "workflows" / "upstream-apm-sync.yml" + + +def _workflow_text() -> str: + return WORKFLOW.read_text(encoding="utf-8") + + +def test_upstream_sync_workflow_fetches_and_merges_microsoft_apm() -> None: + text = _workflow_text() + + assert "https://github.com/microsoft/apm.git" in text + assert "git fetch upstream" in text + assert "git merge --no-ff --no-edit" in text + assert "automation/upstream-microsoft-apm-main" in text + + +def test_upstream_sync_workflow_uses_pr_auto_merge_not_squash() -> None: + text = _workflow_text() + + assert "gh pr create" in text + assert "gh pr merge" in text + assert "--auto --merge --delete-branch" in text + assert "--squash" not in text + + +def test_upstream_sync_workflow_tells_reviewers_to_update_go_coverage() -> None: + text = _workflow_text() + + assert "Review the upstream Python diff" in text + assert "real Go behavior tests" in text + assert "tests/parity/upstream_contract_coverage.yml" in text From 8b374db2adb39ac236e7625a5f45a550b2427a30 Mon Sep 17 00:00:00 2001 From: mrjf Date: Mon, 15 Jun 2026 00:16:45 -0700 Subject: [PATCH 2/2] fix(crane): allow protected files on PR branch pushes --- .github/workflows/crane.lock.yml | 34 ++++++++++++------------ .github/workflows/crane.md | 2 ++ tests/unit/test_crane_scheduler.py | 10 +++++++ tests/unit/test_crane_workflow_prompt.py | 18 +++++++++++++ 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/.github/workflows/crane.lock.yml b/.github/workflows/crane.lock.yml index 50993493..330cc540 100644 --- a/.github/workflows/crane.lock.yml +++ b/.github/workflows/crane.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"945fcb394f5dd72a3c6cbaa1f01ebe75f7bdfa8b2f16531bd2a6276c84c615e7","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"3273a8bd24665a5fe8ac460d33a48355bedb019269053e379d53ac1df52e43e2","compiler_version":"v0.74.4","strict":true,"agent_id":"copilot"} # gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"373c709c69115d41ff229c7e5df9f8788daa9553","version":"v9"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/setup-python","sha":"a309ff8b426b58ec0e2a45f0f869d46889d02405","version":"v6.2.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"d3abfe96a194bce3a523ed2093ddedd5704cdf62","version":"v0.74.4"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.46"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.46"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} # ___ _ _ # / _ \ | | (_) @@ -340,25 +340,25 @@ jobs: run: | bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" { - cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' + cat << 'GH_AW_PROMPT_5e011e6a954fe74a_EOF' - GH_AW_PROMPT_614db64b1dd56d21_EOF + GH_AW_PROMPT_5e011e6a954fe74a_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" cat "${RUNNER_TEMP}/gh-aw/prompts/repo_memory_prompt.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' + cat << 'GH_AW_PROMPT_5e011e6a954fe74a_EOF' Tools: add_comment(max:7), create_issue, update_issue(max:3), create_pull_request, add_labels(max:2), remove_labels(max:2), push_to_pull_request_branch, missing_tool, missing_data, noop - GH_AW_PROMPT_614db64b1dd56d21_EOF + GH_AW_PROMPT_5e011e6a954fe74a_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_push_to_pr_branch.md" - cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' + cat << 'GH_AW_PROMPT_5e011e6a954fe74a_EOF' - GH_AW_PROMPT_614db64b1dd56d21_EOF + GH_AW_PROMPT_5e011e6a954fe74a_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" - cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' + cat << 'GH_AW_PROMPT_5e011e6a954fe74a_EOF' The following GitHub context information is available for this workflow: {{#if github.actor}} @@ -390,7 +390,7 @@ jobs: - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it without proper authentication. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches). - GH_AW_PROMPT_614db64b1dd56d21_EOF + GH_AW_PROMPT_5e011e6a954fe74a_EOF cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md" if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_prompt.md" @@ -398,11 +398,11 @@ jobs: if [ "$GITHUB_EVENT_NAME" = "issue_comment" ] && [ -n "$GH_AW_IS_PR_COMMENT" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review_comment" ] || [ "$GITHUB_EVENT_NAME" = "pull_request_review" ]; then cat "${RUNNER_TEMP}/gh-aw/prompts/pr_context_push_to_pr_branch_guidance.md" fi - cat << 'GH_AW_PROMPT_614db64b1dd56d21_EOF' + cat << 'GH_AW_PROMPT_5e011e6a954fe74a_EOF' {{#runtime-import .github/workflows/shared/reporting.md}} {{#runtime-import .github/workflows/crane.md}} - GH_AW_PROMPT_614db64b1dd56d21_EOF + GH_AW_PROMPT_5e011e6a954fe74a_EOF } > "$GH_AW_PROMPT" - name: Interpolate variables and render templates uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -662,9 +662,9 @@ jobs: mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs" mkdir -p /tmp/gh-aw/safeoutputs mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_910554b6250010b6_EOF' - {"add_comment":{"hide_older_comments":false,"max":7,"target":"*"},"add_labels":{"max":2,"target":"*"},"create_issue":{"labels":["automation","crane"],"max":1},"create_pull_request":{"draft":true,"labels":["automation","crane"],"max":1,"max_patch_files":100,"max_patch_size":10240,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"allowed"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_repo_memory":{"memories":[{"dir":"/tmp/gh-aw/repo-memory/default","id":"default","max_file_count":100,"max_file_size":40960,"max_patch_size":10240}]},"push_to_pull_request_branch":{"if_no_changes":"warn","max":1,"max_patch_size":10240,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"target":"*","title_prefix":"[Crane"},"remove_labels":{"max":2,"target":"*"},"report_incomplete":{},"update_issue":{"allow_body":true,"max":3,"target":"*","title_prefix":"[Crane"}} - GH_AW_SAFE_OUTPUTS_CONFIG_910554b6250010b6_EOF + cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_2239a30918f8a729_EOF' + {"add_comment":{"hide_older_comments":false,"max":7,"target":"*"},"add_labels":{"max":2,"target":"*"},"create_issue":{"labels":["automation","crane"],"max":1},"create_pull_request":{"draft":true,"labels":["automation","crane"],"max":1,"max_patch_files":100,"max_patch_size":10240,"preserve_branch_name":true,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"allowed"},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"push_repo_memory":{"memories":[{"dir":"/tmp/gh-aw/repo-memory/default","id":"default","max_file_count":100,"max_file_size":40960,"max_patch_size":10240}]},"push_to_pull_request_branch":{"if_no_changes":"warn","max":1,"max_patch_size":10240,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"allowed","target":"*","title_prefix":"[Crane"},"remove_labels":{"max":2,"target":"*"},"report_incomplete":{},"update_issue":{"allow_body":true,"max":3,"target":"*","title_prefix":"[Crane"}} + GH_AW_SAFE_OUTPUTS_CONFIG_2239a30918f8a729_EOF - name: Generate Safe Outputs Tools env: GH_AW_TOOLS_META_JSON: | @@ -1053,7 +1053,7 @@ jobs: mkdir -p /home/runner/.copilot GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) - cat << GH_AW_MCP_CONFIG_9d530df1d017e269_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + cat << GH_AW_MCP_CONFIG_d8117bf282596380_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" { "mcpServers": { "github": { @@ -1094,7 +1094,7 @@ jobs: "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" } } - GH_AW_MCP_CONFIG_9d530df1d017e269_EOF + GH_AW_MCP_CONFIG_d8117bf282596380_EOF - name: Mount MCP servers as CLIs id: mount-mcp-clis continue-on-error: true @@ -2023,7 +2023,7 @@ jobs: GH_AW_ALLOWED_DOMAINS: "*.gradle-enterprise.cloud,*.pythonhosted.org,*.vsblob.vsassets.io,adoptium.net,anaconda.org,api.adoptium.net,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.foojay.io,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.nuget.org,api.snapcraft.io,archive.apache.org,archive.ubuntu.com,azure.archive.ubuntu.com,azuresearch-usnc.nuget.org,azuresearch-ussc.nuget.org,binstar.org,bootstrap.pypa.io,builds.dotnet.microsoft.com,bun.sh,cdn.azul.com,cdn.jsdelivr.net,central.sonatype.com,ci.dot.net,conda.anaconda.org,conda.binstar.org,crates.io,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,dc.services.visualstudio.com,deb.nodesource.com,deno.land,develocity.apache.org,dist.nuget.org,dl.google.com,dlcdn.apache.org,dot.net,dotnet.microsoft.com,dotnetcli.blob.core.windows.net,download.eclipse.org,download.java.net,download.oracle.com,downloads.gradle-dn.com,esm.sh,files.pythonhosted.org,ge.spockframework.org,get.pnpm.io,github.com,go.dev,golang.org,googleapis.deno.dev,googlechromelabs.github.io,goproxy.io,gradle.org,host.docker.internal,index.crates.io,jcenter.bintray.com,jdk.java.net,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,maven-central.storage-download.googleapis.com,maven.apache.org,maven.google.com,maven.oracle.com,maven.pkg.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,nuget.org,nuget.pkg.github.com,nugetregistryv2prod.blob.core.windows.net,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,oneocsp.microsoft.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,pip.pypa.io,pkg.go.dev,pkgs.dev.azure.com,plugins-artifacts.gradle.org,plugins.gradle.org,ppa.launchpad.net,proxy.golang.org,pypi.org,pypi.python.org,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.anaconda.com,repo.continuum.io,repo.gradle.org,repo.grails.org,repo.maven.apache.org,repo.spring.io,repo.yarnpkg.com,repo1.maven.org,repository.apache.org,s.symcb.com,s.symcd.com,scans-in.gradle.com,security.ubuntu.com,services.gradle.org,sh.rustup.rs,skimdb.npmjs.com,static.crates.io,static.rust-lang.org,storage.googleapis.com,sum.golang.org,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.java.com,www.microsoft.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":false,\"max\":7,\"target\":\"*\"},\"add_labels\":{\"max\":2,\"target\":\"*\"},\"create_issue\":{\"labels\":[\"automation\",\"crane\"],\"max\":1},\"create_pull_request\":{\"draft\":true,\"labels\":[\"automation\",\"crane\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":10240,\"preserve_branch_name\":true,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"allowed\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max\":1,\"max_patch_size\":10240,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"target\":\"*\",\"title_prefix\":\"[Crane\"},\"remove_labels\":{\"max\":2,\"target\":\"*\"},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"max\":3,\"target\":\"*\",\"title_prefix\":\"[Crane\"}}" + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"hide_older_comments\":false,\"max\":7,\"target\":\"*\"},\"add_labels\":{\"max\":2,\"target\":\"*\"},\"create_issue\":{\"labels\":[\"automation\",\"crane\"],\"max\":1},\"create_pull_request\":{\"draft\":true,\"labels\":[\"automation\",\"crane\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":10240,\"preserve_branch_name\":true,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"allowed\"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"push_to_pull_request_branch\":{\"if_no_changes\":\"warn\",\"max\":1,\"max_patch_size\":10240,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"allowed\",\"target\":\"*\",\"title_prefix\":\"[Crane\"},\"remove_labels\":{\"max\":2,\"target\":\"*\"},\"report_incomplete\":{},\"update_issue\":{\"allow_body\":true,\"max\":3,\"target\":\"*\",\"title_prefix\":\"[Crane\"}}" GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} with: github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/crane.md b/.github/workflows/crane.md index 795c11b7..307ca8fc 100644 --- a/.github/workflows/crane.md +++ b/.github/workflows/crane.md @@ -119,6 +119,7 @@ safe-outputs: push-to-pull-request-branch: target: "*" title-prefix: "[Crane" + protected-files: allowed max: 1 create-issue: labels: [automation, crane] @@ -490,6 +491,7 @@ This Step 0 produces the plan and ships it as commit #1 on the migration branch ``` Use `--force-with-lease` (not `--force`) so concurrent pushes are rejected rather than overwritten. + If the `git merge origin/main` path brings in protected control-plane files such as `.github/aw/actions-lock.json`, `.github/workflows/*.md`, `.github/workflows/*.lock.yml`, or `.github/workflows/scripts/*`, treat those file changes as trusted base-branch sync noise, not migration work. Before committing or calling `push-to-pull-request-branch`, restore those protected files to their pre-merge migration-branch versions with `git checkout ORIG_HEAD -- ` unless the migration explicitly declares them in its Source or Target paths. The safe-output patch for an existing Crane PR must not include protected workflow/config files solely because `origin/main` changed them; otherwise the push will be rejected by the protected-files policy. 2. Make the proposed changes -- restricted to: - Files inside the source paths declared in the migration's **Source** section diff --git a/tests/unit/test_crane_scheduler.py b/tests/unit/test_crane_scheduler.py index 4db57696..84c58019 100644 --- a/tests/unit/test_crane_scheduler.py +++ b/tests/unit/test_crane_scheduler.py @@ -2,6 +2,7 @@ import importlib.util import json +from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[2] @@ -44,6 +45,14 @@ def _write_migration(path: Path, *, schedule: str = "every 6h") -> None: def test_main_exits_zero_and_outputs_no_work_when_no_migrations_are_due( tmp_path, monkeypatch ) -> None: + class FixedDatetime(datetime): + @classmethod + def now(cls, tz=None): + current = cls(2026, 6, 10, 16, 10, 36, tzinfo=timezone.utc) + if tz is None: + return current.replace(tzinfo=None) + return current.astimezone(tz) + _write_migration(tmp_path / ".crane" / "migrations" / "sample.md", schedule="weekly") output_dir = tmp_path / "out" github_output = tmp_path / "github-output.txt" @@ -58,6 +67,7 @@ def test_main_exits_zero_and_outputs_no_work_when_no_migrations_are_due( "read_migration_state", lambda _name: {"last_run": "2026-06-05T16:10:36Z", "iteration_count": 72}, ) + monkeypatch.setattr(crane_scheduler, "datetime", FixedDatetime) monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) assert crane_scheduler.main() == 0 diff --git a/tests/unit/test_crane_workflow_prompt.py b/tests/unit/test_crane_workflow_prompt.py index 2f48a143..ee6b3d95 100644 --- a/tests/unit/test_crane_workflow_prompt.py +++ b/tests/unit/test_crane_workflow_prompt.py @@ -55,6 +55,24 @@ def test_crane_completion_is_two_phase_and_pr_head_gated() -> None: assert "Completion Gate Status: passed:" in text +def test_crane_base_sync_strips_protected_workflow_files_from_push_patch() -> None: + text = _workflow_text() + + assert "trusted base-branch sync noise" in text + assert "git checkout ORIG_HEAD -- " in text + assert ( + "safe-output patch for an existing Crane PR must not include protected workflow/config files" + in text + ) + + +def test_crane_push_to_pr_branch_allows_protected_files() -> None: + text = _workflow_text() + + push_config = text.split("push-to-pull-request-branch:", 1)[1].split("create-issue:", 1)[0] + assert "protected-files: allowed" in push_config + + def test_crane_state_template_tracks_completion_candidate_gate() -> None: text = _workflow_text()