diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml index f33347a9ca7..e3331ac6776 100644 --- a/.github/workflows/fork-ci.yml +++ b/.github/workflows/fork-ci.yml @@ -129,10 +129,64 @@ jobs: - name: Exercise release-only workflow steps run: node scripts/release-smoke.ts + deployment_scope: + name: Classify Deployment Scope + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration' + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + deploy: ${{ steps.classify.outputs.deploy }} + steps: + - name: Checkout integration source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Find previous successful integration CI + id: previous + env: + GH_TOKEN: ${{ github.token }} + run: | + previous_sha="$( + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/fork-ci.yml/runs" \ + -f branch=fork/integration \ + -f event=workflow_dispatch \ + -f status=success \ + -f per_page=20 \ + --jq ".workflow_runs | map(select(.head_sha != \"${GITHUB_SHA}\")) | first | .head_sha // \"\"" + )" + echo "sha=${previous_sha}" >>"${GITHUB_OUTPUT}" + + - name: Classify changes since previous successful integration CI + id: classify + env: + PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} + run: | + deploy=true + if [[ "${PREVIOUS_SHA}" =~ ^[0-9a-f]{40}$ ]] && + git fetch --quiet origin "${PREVIOUS_SHA}" && + git cat-file -e "${PREVIOUS_SHA}^{commit}"; then + deploy="$( + scripts/classify-deployment-diff.sh "${PREVIOUS_SHA}" "${GITHUB_SHA}" | + tee /dev/stderr | + sed -n 's/^deploy=//p' + )" + else + echo "No fetchable previous successful integration SHA; deployment remains enabled." + fi + echo "deploy=${deploy}" >>"${GITHUB_OUTPUT}" + dispatch_mobile_ota: name: Dispatch Mobile OTA - needs: [check, test, mobile_native_static_analysis, release_smoke] - if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/fork/integration' + needs: [check, test, mobile_native_static_analysis, release_smoke, deployment_scope] + if: | + github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/fork/integration' && + needs.deployment_scope.outputs.deploy == 'true' runs-on: ubuntu-24.04 timeout-minutes: 5 permissions: diff --git a/AGENTS.md b/AGENTS.md index 8fd593fdf15..614cd9032ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,9 @@ branches. deployment. Do not re-enable or target those workflows for fork releases. - Updating `fork/tim` or merging a PR into `fork/changes` triggers the stack workflow, which rebases the provenance layers, rebuilds `fork/integration`, and dispatches CI for its exact SHA. -- Successful `fork/integration` CI hands the exact tested SHA to the private operations repository. +- Successful `fork/integration` CI classifies the complete tree diff from the previous approved + integration tree. Runtime-affecting changes hand the exact tested SHA to the private operations + repository; tests, documentation, agent metadata, and GitHub-only metadata do not deploy. - Machine topology and deployment implementation belong in a separate private operations repository, not this repository. diff --git a/docs/fork-stack.md b/docs/fork-stack.md index 36ac9175c33..c31754996ef 100644 --- a/docs/fork-stack.md +++ b/docs/fork-stack.md @@ -61,9 +61,16 @@ feature PR merged into fork/changes → rebase-pr-stack workflow → fork/integration updated atomically → CI dispatched for the exact integration SHA - → successful CI triggers fleet deployment + → successful CI classifies the tree diff + → runtime-affecting changes trigger fleet deployment + → test, documentation, and automation-only changes stop after CI ``` +Deployment classification compares complete tested integration trees rather than only the latest +commit. Unknown paths are runtime-affecting by default. This preserves safe deployment when a PR +contains mixed changes or a new source directory appears, while avoiding fleet rebuilds and mobile +OTA updates for tests, snapshots, documentation, agent instructions, and GitHub-only metadata. + The manifest contains the permanent `fork/tim` PR followed by the permanent `fork/changes` PR. The synchronizer rebases that provenance chain onto the latest upstream `main` and rebuilds `fork/integration`. Other open repository PRs are ignored. Temporary state is retained after a diff --git a/scripts/classify-deployment-diff.sh b/scripts/classify-deployment-diff.sh new file mode 100755 index 00000000000..9e356759ec7 --- /dev/null +++ b/scripts/classify-deployment-diff.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash + +set -euo pipefail + +base_sha="${1:-}" +head_sha="${2:-}" + +if [[ ! "${base_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ ! "${head_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +is_non_runtime_path() { + case "$1" in + .agents/* | .github/* | docs/* | \ + AGENTS.md | CLAUDE.md | README.md | */README.md | \ + *.md | *.mdx | *.snap | \ + *.test.* | *.spec.* | \ + test/* | tests/* | */test/* | */tests/* | \ + */__snapshots__/* | */__tests__/* | */testUtils/* | */fixtures/* | \ + apps/server/scripts/acp-mock-agent.ts | scripts/release-smoke.ts) + return 0 + ;; + *) + return 1 + ;; + esac +} + +runtime_paths=() +non_runtime_paths=() +while IFS= read -r -d '' path; do + if is_non_runtime_path "${path}"; then + non_runtime_paths+=("${path}") + else + runtime_paths+=("${path}") + fi +done < <(git diff --name-only -z "${base_sha}" "${head_sha}") + +printf 'Changed paths: %d runtime, %d non-runtime\n' \ + "${#runtime_paths[@]}" "${#non_runtime_paths[@]}" + +if ((${#runtime_paths[@]} > 0)); then + printf 'Runtime-affecting paths:\n' + printf ' %s\n' "${runtime_paths[@]}" + printf 'deploy=true\n' +else + printf 'Only tests, documentation, agent metadata, or CI metadata changed.\n' + printf 'deploy=false\n' +fi