From 86f1c8f9180854675b754bb25621309795d8d2d9 Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 20:08:00 -0600 Subject: [PATCH] codex: classify every release pull request state GitHub presents reviewed topics, mechanical plan transitions, and controller changes as ordinary pull requests even though they have different review and publication paths. The existing reconciler also skipped draft topics with noncanonical names and ordinary meta changes, leaving most open Codex pull requests without useful state. Derive one role, build target, and lifecycle state for every open pull request against codex, codex-unstable, or meta, as well as retained plan history. Mark invalid topic names and failed admission or merge gates as blocked instead of silently omitting them. Abort before writing labels if any pull request is missing a classification or receives more than one. Keep planned, staged, and integrated states tied to the exact frozen source and output metadata. Preserve unrelated labels, recheck live heads before mutation, and retain a read-only dry run for auditing the complete classification. --- .github/CODEX.md | 43 ++ .github/workflows/codex-branch.sh | 62 ++- .github/workflows/codex-pr-state.sh | 630 +++++++++++++++++++++++++++ .github/workflows/codex-pr-state.yml | 66 +++ t/t9905-codex-branch.sh | 583 ++++++++++++++++++++++++- 5 files changed, 1378 insertions(+), 6 deletions(-) create mode 100755 .github/workflows/codex-pr-state.sh create mode 100644 .github/workflows/codex-pr-state.yml diff --git a/.github/CODEX.md b/.github/CODEX.md index 03e31c090ddbeb..55b4d7f84f3388 100644 --- a/.github/CODEX.md +++ b/.github/CODEX.md @@ -67,6 +67,43 @@ that PR as closed rather than merged. Staging alone never closes a PR, and a closure failure cannot undo an otherwise successful publication. A later change to the same topic needs another topic PR. +## Pull request labels + +Topic pull requests are review-only: approve them, but do not merge them. +Automatic add/alter plans, human remove/reorder plans, and ordinary controller +changes are distinct `meta` changes. Every open PR against `codex`, +`codex-unstable`, or `meta`, plus retained plan history, receives one role, +one build, and one lifecycle state: + +- `kind:review-only`, `kind:auto-plan`, `kind:plan-policy`, or + `kind:controller` identifies why the PR exists. +- `build:codex-stable`, `build:codex-unstable`, or + `build:codex-controller` identifies what it affects. +- `codex:draft`, `codex:needs-review`, `codex:ready`, + `codex:awaiting-plan`, `codex:planned`, `codex:staged`, + `codex:integrated`, or `codex:superseded` identifies its lifecycle state. +- `codex:blocked` is additional when an invalid topic name, failed admission, + merge conflict, or merge policy prevents the PR from advancing. + +Labels are derived presentation, not admission or release policy. A topic is +planned only when its exact current head is recorded in its build plan, and +integrated only when `codex.config` records that same source head and its +output tip matches the live build branch. Staging a rebased topic requires +the frozen generation's verified candidate ledger; a rewritten integration +commit is not confused with the reviewed source commit. A moved source head +falls back to review or admission instead of inheriting an earlier state. The +reconciler refuses missing, duplicate, or contradictory classifications before +writing any labels. + +The trusted default-branch scanner refreshes labels periodically. The local +publisher refreshes them after each candidate is staged and again after +atomic promotion; presentation failures warn but never change publication. +To inspect the current projection without changing labels: + +```sh +Meta/codex reconcile-pr-state --dry-run +``` + Remove and reorder are policy decisions rather than projections of a reviewed topic head. Run **Actions > Refresh codex > Run workflow** with `operation=remove` or `operation=reorder`; the resulting plan PR needs the @@ -165,6 +202,7 @@ default-branch trampoline: - dispatches ordinary refresh; - scans approved topic PRs from the trusted default branch and creates one automatic add/alter proposal at a time; +- reconciles derived PR labels from the trusted `meta` controller; - offers explicit remove/reorder dispatch inputs; and - runs plan admission through `pull_request_target` while loading the reusable implementation from `meta`. @@ -174,6 +212,11 @@ that trampoline. During migration, the controller accepts the previous trampoline as published history, but it refuses the first v3 refresh until the plan pins the current trampoline. +The label-aware trampoline is a backward-compatible upgrade: the already +published pinned-plan trampoline remains valid while its reviewed automation +topic is updated. Once the label-aware trampoline is published, moving back to +the earlier version is rejected. + No topic merge ref runs the pinning path. The default-branch scanner checks each open approved PR with the trusted `meta` controller, skips tips already present in the active plans or represented by an open plan PR, and only then diff --git a/.github/workflows/codex-branch.sh b/.github/workflows/codex-branch.sh index 6f6f9ec6911067..ea7a8e60f00a7d 100755 --- a/.github/workflows/codex-branch.sh +++ b/.github/workflows/codex-branch.sh @@ -50,6 +50,8 @@ usage () { or: codex-branch validate-topic-review --pull-request --lane --topic --source-tip + or: codex-branch reconcile-pr-state [--expected-meta ] + [--inputs --updates ] [--dry-run] or: codex-branch propose-plan [--remote ] --lane --topic [--source-tip ] [--review-pr ] @@ -330,6 +332,8 @@ legacy_control_paths_unchanged () ( .github/workflows/codex-admission.yml \ .github/workflows/codex-plan-admission.yml \ .github/workflows/codex-plan-propose.yml \ + .github/workflows/codex-pr-state.sh \ + .github/workflows/codex-pr-state.yml \ .github/workflows/codex-topic.yml \ .github/workflows/codex.yml \ .github/workflows/codex-branch.sh \ @@ -358,6 +362,8 @@ meta_control_paths_unchanged () ( .github/workflows/codex-admission.yml \ .github/workflows/codex-plan-admission.yml \ .github/workflows/codex-plan-propose.yml \ + .github/workflows/codex-pr-state.sh \ + .github/workflows/codex-pr-state.yml \ .github/workflows/codex-topic.yml \ .github/workflows/codex-branch.sh \ .github/workflows/main.yml \ @@ -621,9 +627,25 @@ jobs: contents: read pull-requests: write uses: openai/git/.github/workflows/codex-plan-admission.yml@meta + pr_state: + name: Reconcile Codex pull request state + if: >- + github.event_name == 'schedule' || + (github.event_name == 'workflow_dispatch' && + github.ref == 'refs/heads/codex' && + inputs.operation == 'scan') + permissions: + contents: read + issues: write + pull-requests: write + uses: openai/git/.github/workflows/codex-pr-state.yml@meta EOF } +write_previous_pinned_automation_workflow () { + write_automation_workflow | sed '/^ pr_state:$/,$d' +} + write_previous_automation_workflow () { cat <<-'EOF' name: Refresh codex @@ -730,7 +752,7 @@ write_legacy_automation_workflow () { EOF } -automation_workflow_is_current () { +automation_workflow_is_latest () { head_oid=$1 make_tmp_dir git show "$head_oid:.github/workflows/codex.yml" \ @@ -740,6 +762,17 @@ automation_workflow_is_current () { "$tmp_dir/actual-automation.yml" } +automation_workflow_is_current () { + if automation_workflow_is_latest "$1" + then + return 0 + fi + write_previous_pinned_automation_workflow \ + >"$tmp_dir/expected-automation.yml" + cmp -s "$tmp_dir/expected-automation.yml" \ + "$tmp_dir/actual-automation.yml" +} + automation_workflow_is_reviewed () { head_oid=$1 if automation_workflow_is_current "$head_oid" @@ -7995,6 +8028,8 @@ topic_control_paths_unchanged () ( .github/workflows/codex-admission.yml \ .github/workflows/codex-plan-admission.yml \ .github/workflows/codex-plan-propose.yml \ + .github/workflows/codex-pr-state.sh \ + .github/workflows/codex-pr-state.yml \ .github/workflows/codex-topic.yml \ .github/workflows/codex.yml \ .github/workflows/codex-branch.sh \ @@ -8136,6 +8171,11 @@ verify_control_paths () { release_publication_controls_preserved "$published_codex" \ "$candidate" || die "candidate changes the controller-only release publication guard" + if automation_workflow_is_latest "$published_codex" && + ! automation_workflow_is_latest "$candidate" + then + die "candidate downgrades the canonical Codex admission workflow" + fi if automation_workflow_is_current "$published_codex" && ! automation_workflow_is_current "$candidate" then @@ -9540,6 +9580,19 @@ prepare_local_candidate () { freeze_local_candidate "$session" "$local_candidate_dir" } +reconcile_candidate_pr_state () ( + inputs=$1 + updates=$2 + helper=$script_dir/codex-pr-state.sh + test -f "$helper" || return 0 + if ! sh "$helper" --inputs "$inputs" --updates "$updates" + then + printf '%s\n' \ + 'warning: could not reconcile derived Codex pull request labels' \ + >&2 + fi +) + stage_and_wait_for_ci () { repository=$1 candidate=$2 @@ -9560,6 +9613,7 @@ stage_and_wait_for_ci () { say "GitHub API user: $publisher" stage_candidate --remote origin --staging "$staging" \ --inputs "$inputs" --updates "$updates" --require-automation + reconcile_candidate_pr_state "$inputs" "$updates" wait_for_staging_ci gh "$repository" "$candidate" "$baseline" \ "$staging" unstable_candidate=$(awk -F '\t' \ @@ -9577,6 +9631,7 @@ stage_and_wait_for_ci () { esac stage_candidate --remote origin --staging "$staging" \ --inputs "$inputs" --updates "$updates" --require-automation + reconcile_candidate_pr_state "$inputs" "$updates" wait_for_staging_ci gh "$repository" "$unstable_candidate" \ "$baseline" "$staging" fi @@ -9690,6 +9745,8 @@ rebuild_codex_locally () { --inputs "$local_candidate_dir/codex-inputs" \ --updates "$local_candidate_dir/codex-updates" \ --require-automation + reconcile_candidate_pr_state "$local_candidate_dir/codex-inputs" \ + "$local_candidate_dir/codex-updates" say "Published codex candidate $candidate from local preparation session $session." if ! close_published_topic_reviews "$controller_oid" \ "$local_candidate_dir/codex-updates" @@ -9814,6 +9871,8 @@ publish_run () { promote --remote origin --staging codex-staging \ --inputs "$metadata/codex-inputs" \ --updates "$metadata/codex-updates" --require-automation + reconcile_candidate_pr_state "$metadata/codex-inputs" \ + "$metadata/codex-updates" say "Published codex candidate $artifact_candidate from Actions run $run_id." if ! close_published_topic_reviews "$run_controller" \ "$metadata/codex-updates" @@ -10125,6 +10184,7 @@ verify-inputs) verify_inputs "$@" ;; validate-plan-transition) validate_plan_transition "$@" ;; test-validate-plan-transition) validate_plan_transition_fixture "$@" ;; validate-topic-review) validate_topic_review "$@" ;; +reconcile-pr-state) sh "$script_dir/codex-pr-state.sh" "$@" ;; propose-plan) propose_plan "$@" ;; recover-release-pin) recover_release_pin "$@" ;; test-recover-release-pin) recover_release_pin_fixture "$@" ;; diff --git a/.github/workflows/codex-pr-state.sh b/.github/workflows/codex-pr-state.sh new file mode 100755 index 00000000000000..f0796f59e5bcd0 --- /dev/null +++ b/.github/workflows/codex-pr-state.sh @@ -0,0 +1,630 @@ +#!/bin/sh + +set -eu + +me=codex-pr-state +repository=${GITHUB_REPOSITORY:-openai/git} +expected_meta= +inputs= +updates= +dry_run= +tab=$(printf '\t') +script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) +state_dir= + +die () { + printf '%s: %s\n' "$me" "$*" >&2 + exit 1 +} + +cleanup () { + test -z "$state_dir" || rm -rf "$state_dir" +} + +trap cleanup EXIT HUP INT TERM + +require_arg () { + test $# -ge 2 || die "$1 needs one argument" +} + +while test $# -gt 0 +do + case "$1" in + --expected-meta) + require_arg "$@" + expected_meta=$2 + shift 2 + ;; + --inputs) + require_arg "$@" + inputs=$2 + shift 2 + ;; + --updates) + require_arg "$@" + updates=$2 + shift 2 + ;; + --dry-run) + dry_run=t + shift + ;; + *) die "unknown option '$1'" ;; + esac +done + +test "$repository" = openai/git || + die "pull request state can only be reconciled for openai/git" +if test -n "$inputs" || test -n "$updates" +then + test -n "$inputs" && test -n "$updates" || + die "candidate provenance requires both --inputs and --updates" + test -f "$inputs" || die "input snapshot '$inputs' does not exist" + test -f "$updates" || die "update manifest '$updates' does not exist" +fi + +state_dir=$(mktemp -d "${TMPDIR:-/tmp}/codex-pr-state.XXXXXX") || + die "could not create temporary state" + +labels () { + cat <<-'EOF' + kind:review-only 5319e7 Reviewed topic; do not merge this pull request + kind:auto-plan c5def5 Automatically admitted Codex plan transition + kind:plan-policy d4c5f9 Human-reviewed Codex plan policy change + kind:controller bfd4f2 Codex release controller change + build:codex-stable 0e8a16 Production Codex Git build + build:codex-unstable fbca04 Preview Codex Git build + build:codex-controller 8250df Codex release controller + codex:draft ededed Draft; no review action requested + codex:needs-review d93f0b Current topic head needs a qualifying review + codex:ready 0e8a16 Approved ordinary change is ready for normal merge + codex:awaiting-plan fbca04 Reviewed head is waiting for a pinned plan + codex:planned c2e0c6 Reviewed head is pinned in the desired build plan + codex:staged 1d76db Exact planned head is in a staged build + codex:integrated 0e8a16 Exact planned head is in the published build + codex:superseded cfd3d7 Plan proposal has been replaced or closed + codex:blocked b60205 Current state needs intervention before it can advance + EOF +} + +is_full_oid () { + case "$1" in + ''|*[!0-9a-f]*) return 1 ;; + esac + test "${#1}" = 40 +} + +snapshot_refs () { + query='query($owner:String!,$name:String!){repository(owner:$owner,name:$name){meta:ref(qualifiedName:"refs/heads/meta"){target{oid}}stable:ref(qualifiedName:"refs/heads/codex"){target{oid}}unstable:ref(qualifiedName:"refs/heads/codex-unstable"){target{oid}}stableStage:ref(qualifiedName:"refs/heads/codex-staging"){target{oid}}unstableStage:ref(qualifiedName:"refs/heads/codex-unstable-staging"){target{oid}}}}' + gh api --hostname github.com graphql \ + -F owner=openai -F name=git -f "query=$query" \ + --jq '[.data.repository.meta.target.oid, + (.data.repository.stable.target.oid // "-"), + (.data.repository.unstable.target.oid // "-"), + (.data.repository.stableStage.target.oid // "-"), + (.data.repository.unstableStage.target.oid // "-")] | @tsv' \ + >"$state_dir/refs" || + die "could not inspect Codex controller and output refs" + test "$(wc -l <"$state_dir/refs" | tr -d ' ')" = 1 || + die "GitHub returned an ambiguous Codex ref snapshot" + IFS="$tab" read -r meta stable unstable stable_stage unstable_stage \ + <"$state_dir/refs" || die "could not parse Codex ref snapshot" + is_full_oid "$meta" || die "meta is not a full commit object ID" + is_full_oid "$stable" || die "codex is not a full commit object ID" + for oid in "$unstable" "$stable_stage" "$unstable_stage" + do + test "$oid" = - || is_full_oid "$oid" || + die "Codex ref snapshot contains an invalid object ID" + done + test -z "$expected_meta" || test "$meta" = "$expected_meta" || + die "meta moved from $expected_meta to $meta" + git cat-file -e "$meta^{commit}" || + die "trusted meta commit '$meta' is not available locally" + git show "$meta:codex.plan" >"$state_dir/codex.plan" || + die "trusted meta has no stable plan" + git show "$meta:codex.config" >"$state_dir/codex.config" || + die "trusted meta has no published-state ledger" + if test "$unstable" != - + then + git show "$meta:codex-unstable.plan" \ + >"$state_dir/codex-unstable.plan" || + die "trusted meta has no unstable plan" + fi + if test -n "$updates" + then + candidate_controller=$(awk -F '\t' \ + '$1 == "controller" { print $3 }' "$inputs") + candidate_meta=$(awk -F '\t' \ + '$1 == "refs/heads/meta" { print $3 }' "$updates") + if test "$candidate_controller" = "$meta" && + is_full_oid "$candidate_meta" + then + git show "$candidate_meta:codex.config" \ + >"$state_dir/candidate.config" || + die "candidate meta has no realized-state ledger" + fi + fi +} + +ensure_labels () { + test -z "$dry_run" || return 0 + gh api --hostname github.com \ + "repos/$repository/labels?per_page=100" --paginate \ + --jq '.[].name' >"$state_dir/existing-labels" || + die "could not inspect repository labels" + while IFS="$tab" read -r label color description + do + if grep -F -x "$label" "$state_dir/existing-labels" \ + >/dev/null + then + continue + fi + gh api --hostname github.com --method POST \ + "repos/$repository/labels" \ + -f "name=$label" -f "color=$color" \ + -f "description=$description" >/dev/null || + die "could not create repository label '$label'" + done <"$state_dir/managed-labels" +} + +has_label () { + printf '%s\n' "$1" | + jq -e --arg label "$2" 'index($label) != null' >/dev/null +} + +lane_plan () { + case "$1" in + codex) printf '%s\n' "$state_dir/codex.plan" ;; + codex-unstable) printf '%s\n' "$state_dir/codex-unstable.plan" ;; + *) die "unknown Codex build '$1'" ;; + esac +} + +lane_output () { + case "$1" in + codex) printf '%s\n' "$stable" ;; + codex-unstable) printf '%s\n' "$unstable" ;; + *) die "unknown Codex build '$1'" ;; + esac +} + +lane_staging () { + case "$1" in + codex) printf '%s\n' "$stable_stage" ;; + codex-unstable) printf '%s\n' "$unstable_stage" ;; + *) die "unknown Codex build '$1'" ;; + esac +} + +build_label () { + case "$1" in + codex) printf '%s\n' build:codex-stable ;; + codex-unstable) printf '%s\n' build:codex-unstable ;; + meta) printf '%s\n' build:codex-controller ;; + *) die "unknown Codex build '$1'" ;; + esac +} + +planned_tip () { + plan=$(lane_plan "$1") + test -f "$plan" || return 0 + git config --no-includes --file "$plan" \ + --get "branch.$2.source-tip" || : +} + +published_tip () { + git config --no-includes --file "$state_dir/codex.config" \ + --get "branch.$1.source-tip" || : +} + +published_output () { + git config --no-includes --file "$state_dir/codex.config" \ + --get "$1.output-tip" || : +} + +staged_topic () { + lane=$1 + topic=$2 + source_tip=$3 + staging=$(lane_staging "$lane") + test "$staging" != - || return 1 + + if test -f "$state_dir/candidate.config" + then + candidate_tip=$(git config --no-includes \ + --file "$state_dir/candidate.config" \ + --get "branch.$topic.source-tip" || :) + candidate_output=$(git config --no-includes \ + --file "$state_dir/candidate.config" \ + --get "$lane.output-tip" || :) + candidate_plan=$(git config --no-includes \ + --file "$state_dir/candidate.config" \ + --get "$lane.applied-plan" || :) + candidate_update=$(awk -F '\t' \ + -v ref="refs/heads/$lane" \ + '$1 == ref { print $3 }' "$updates") + plan_path=$(lane_plan "$lane") + expected_plan=$(git hash-object "$plan_path") + test "$candidate_tip" = "$source_tip" && + test "$candidate_output" = "$staging" && + test "$candidate_update" = "$staging" && + test "$candidate_plan" = "$expected_plan" && return 0 + fi + + git cat-file -e "$staging^{commit}" 2>/dev/null || return 1 + generated_tip=$(git log --first-parent --max-count=256 \ + --format='%(trailers:key=Codex-Integration,valueonly)' \ + "$staging" | + awk -v prefix="$topic@" ' + index($0, prefix) == 1 { + print substr($0, length(prefix) + 1) + exit + } + ') || return 1 + test -n "$generated_tip" || return 1 + test "$generated_tip" = "$source_tip" +} + +qualifying_approved () { + pull_number=$1 + lane=$2 + topic=$3 + source_tip=$4 + sh "$script_dir/codex-branch.sh" validate-topic-review \ + --pull-request "$pull_number" --lane "$lane" \ + --topic "$topic" --source-tip "$source_tip" \ + >"$state_dir/review.out" 2>"$state_dir/review.err" && + return 0 + if grep -Eq 'could not (inspect|read)' "$state_dir/review.err" + then + cat "$state_dir/review.err" >&2 + die "could not verify pull request #$pull_number approval" + fi + return 1 +} + +state_for_topic () { + lane=$1 + topic=$2 + source_tip=$3 + planned=$(planned_tip "$lane" "$topic") + if test "$planned" != "$source_tip" + then + computed_state= + return + fi + published=$(published_tip "$topic") + recorded_output=$(published_output "$lane") + live_output=$(lane_output "$lane") + if test "$published" = "$source_tip" && + test "$recorded_output" = "$live_output" + then + computed_state=codex:integrated + elif staged_topic "$lane" "$topic" "$source_tip" + then + computed_state=codex:staged + else + computed_state=codex:planned + fi +} + +is_desired_label () { + label=$1 + test "$label" = "$desired_role" && return 0 + test "$label" = "$desired_build" && return 0 + test "$label" = "$desired_state" && return 0 + test "$label" = codex:blocked && test "$desired_blocked" = true +} + +sync_labels () { + pull_number=$1 + expected_head=$2 + current_labels=$3 + desired_role=$4 + desired_build=$5 + desired_state=$6 + desired_blocked=$7 + block_reason=$8 + + if test -n "$dry_run" + then + printf '#%s\t%s\t%s\t%s' "$pull_number" "$desired_role" \ + "$desired_build" "$desired_state" + test "$desired_blocked" != true || printf '\tcodex:blocked' + test "$block_reason" = - || printf '\t%s' "$block_reason" + printf '\n' + return + fi + + actual_head=$(gh api --hostname github.com \ + "repos/$repository/pulls/$pull_number" --jq .head.sha) || + die "could not recheck the head of pull request #$pull_number" + if test "$actual_head" != "$expected_head" + then + printf 'Skipping pull request #%s: its head moved.\n' \ + "$pull_number" >&2 + return + fi + + while IFS="$tab" read -r label color description + do + if is_desired_label "$label" + then + if ! has_label "$current_labels" "$label" + then + gh api --hostname github.com --method POST \ + "repos/$repository/issues/$pull_number/labels" \ + -f "labels[]=$label" >/dev/null || + die "could not add '$label' to pull request #$pull_number" + fi + elif has_label "$current_labels" "$label" + then + encoded=$(jq -nr --arg label "$label" '$label | @uri') + gh api --hostname github.com --method DELETE \ + "repos/$repository/issues/$pull_number/labels/$encoded" \ + >/dev/null || + die "could not remove '$label' from pull request #$pull_number" + fi + done <"$state_dir/managed-labels" + printf '#%s: %s %s %s\n' "$pull_number" "$desired_role" \ + "$desired_build" "$desired_state" +} + +record_classification () { + pull_number=$1 + expected_head=$2 + current_labels=$3 + desired_role=$4 + desired_build=$5 + desired_state=$6 + desired_blocked=$7 + block_reason=${8:--} + case "$pull_number" in + ''|*[!0-9]*) die "invalid pull request number '$pull_number'" ;; + esac + is_full_oid "$expected_head" || + die "pull request #$pull_number has an invalid head SHA" + case "$desired_role" in + kind:review-only|kind:auto-plan|kind:plan-policy|kind:controller) ;; + *) die "pull request #$pull_number has unknown role '$desired_role'" ;; + esac + case "$desired_build" in + build:codex-stable|build:codex-unstable|build:codex-controller) ;; + *) die "pull request #$pull_number has unknown build '$desired_build'" ;; + esac + case "$desired_state" in + codex:draft|codex:needs-review|codex:ready|codex:awaiting-plan|\ + codex:planned|codex:staged|codex:integrated|codex:superseded) ;; + *) die "pull request #$pull_number has unknown state '$desired_state'" ;; + esac + case "$desired_blocked:$block_reason" in + false:-|true:blocked:*) ;; + *) die "pull request #$pull_number has an invalid blocker '$block_reason'" ;; + esac + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$pull_number" "$expected_head" "$current_labels" "$desired_role" \ + "$desired_build" "$desired_state" "$desired_blocked" \ + "$block_reason" >>"$state_dir/classifications" || + die "could not record pull request #$pull_number classification" +} + +apply_classifications () { + cut -f1 "$state_dir/classifications" | LC_ALL=C sort | uniq -d \ + >"$state_dir/duplicate-classifications" || + die "could not validate pull request classifications" + test ! -s "$state_dir/duplicate-classifications" || + die "a pull request received more than one classification" + ensure_labels + while IFS="$tab" read -r pull_number expected_head current_labels \ + desired_role desired_build desired_state desired_blocked block_reason + do + test -n "$pull_number" || continue + sync_labels "$pull_number" "$expected_head" "$current_labels" \ + "$desired_role" "$desired_build" "$desired_state" \ + "$desired_blocked" "$block_reason" + done <"$state_dir/classifications" +} + +valid_topic_name () { + lane=$1 + topic=$2 + case "$topic" in + ??/codex/*) ;; + *) return 1 ;; + esac + case "$topic" in + *-wip|*-stale|??/codex/*/*) return 1 ;; + esac + case "$lane:$topic" in + codex:*-unstable) return 1 ;; + codex:*) ;; + codex-unstable:*-unstable) ;; + *) return 1 ;; + esac +} + +classify_topics () { + lane=$1 + test "$lane" != codex-unstable || test "$unstable" != - || return 0 + gh pr list --repo "$repository" --state open --base "$lane" \ + --limit 1000 \ + --json number,isDraft,headRefName,headRefOid,headRepository,reviewDecision,labels \ + >"$state_dir/$lane-topics.json" || + die "could not list $lane topic pull requests" + jq -r ' + .[] | + [(.number | tostring), (.isDraft | tostring), + .headRefName, .headRefOid, + (.headRepository.nameWithOwner // "-"), + (.reviewDecision | + if . == null or . == "" then "-" else . end), + ([.labels[].name] | @json)] | @tsv + ' "$state_dir/$lane-topics.json" >"$state_dir/$lane-topics" || + die "could not parse $lane topic pull requests" + while IFS="$tab" read -r pull_number draft topic source_tip \ + head_repository review_decision current_labels + do + test -n "$pull_number" || continue + blocked=false + block_reason=- + if test "$head_repository" != "$repository" + then + blocked=true + block_reason=blocked:foreign-head-repository + elif ! valid_topic_name "$lane" "$topic" + then + blocked=true + block_reason=blocked:invalid-topic-name + fi + state_for_topic "$lane" "$topic" "$source_tip" + if test -z "$computed_state" + then + if test "$draft" = true + then + computed_state=codex:draft + else + computed_state=codex:needs-review + fi + if test "$blocked" = false && test "$draft" = false && + test "$review_decision" = APPROVED && + qualifying_approved "$pull_number" "$lane" \ + "$topic" "$source_tip" + then + computed_state=codex:awaiting-plan + fi + fi + record_classification "$pull_number" "$source_tip" \ + "$current_labels" kind:review-only "$(build_label "$lane")" \ + "$computed_state" "$blocked" "$block_reason" + done <"$state_dir/$lane-topics" +} + +classify_meta () { + gh pr list --repo "$repository" --state all --base meta \ + --limit 1000 \ + --json number,state,isDraft,headRefName,headRefOid,body,labels,reviewDecision,mergeStateStatus,statusCheckRollup \ + >"$state_dir/plans.json" || + die "could not list Codex meta pull requests" + jq -r ' + def field($name): + [(.body // "" | split("\n")[]) | + select(startswith("- " + $name + ": `")) | + ltrimstr("- " + $name + ": `") | + rtrimstr("`")] | .[0] // "-"; + .[] | + [(.number | tostring), .state, (.isDraft | tostring), + .headRefName, .headRefOid, + field("Lane"), field("Action"), + (field("Topic") | sub("^refs/heads/"; "")), + field("Source tip"), + ([.labels[].name] | @json), + (any(.statusCheckRollup[]?; + (.name // .context // "") == + "Codex plan admission / Verify pinned manifest" and + (.conclusion // .state // "") == "FAILURE") | tostring), + (.reviewDecision | + if . == null or . == "" then "-" else . end), + (.mergeStateStatus | + if . == null or . == "" then "-" else . end)] | @tsv + ' "$state_dir/plans.json" >"$state_dir/plans" || + die "could not parse Codex meta pull requests" + while IFS="$tab" read -r pull_number pull_state draft head_ref head_oid \ + lane action topic source_tip current_labels admission_failed \ + review_decision merge_state + do + test -n "$pull_number" || continue + case "$head_ref" in + codex-plan/*) ;; + *) + test "$pull_state" = OPEN || continue + phase=codex:needs-review + test "$draft" != true || phase=codex:draft + test "$draft" = true || + test "$review_decision" != APPROVED || + phase=codex:ready + blocked=false + block_reason=- + if test "$phase" = codex:ready + then + case "$merge_state" in + DIRTY) + blocked=true + block_reason=blocked:merge-conflict + ;; + BLOCKED) + blocked=true + block_reason=blocked:merge-policy + ;; + esac + fi + record_classification "$pull_number" "$head_oid" \ + "$current_labels" kind:controller \ + "$(build_label meta)" "$phase" "$blocked" \ + "$block_reason" + continue + ;; + esac + case "$lane" in + codex) ;; + codex-unstable) + test "$unstable" != - || + die "plan pull request #$pull_number targets a disabled preview build" + ;; + *) die "plan pull request #$pull_number has unknown build '$lane'" ;; + esac + case "$action" in + add|alter) role=kind:auto-plan ;; + remove|reorder) role=kind:plan-policy ;; + *) die "plan pull request #$pull_number has unknown action '$action'" ;; + esac + blocked=false + block_reason=- + case "$pull_state" in + CLOSED) phase=codex:superseded ;; + MERGED) + phase=codex:superseded + if test "$source_tip" != - + then + state_for_topic "$lane" "$topic" "$source_tip" + test -z "$computed_state" || phase=$computed_state + fi + ;; + OPEN) + if test "$draft" = true + then + phase=codex:draft + elif test "$role" = kind:plan-policy + then + phase=codex:needs-review + test "$review_decision" != APPROVED || phase=codex:ready + else + phase=codex:awaiting-plan + fi + if test "$source_tip" != - && + test "$(planned_tip "$lane" "$topic")" = \ + "$source_tip" + then + phase=codex:superseded + fi + if test "$phase" != codex:superseded && + test "$admission_failed" = true + then + blocked=true + block_reason=blocked:admission-failed + fi + ;; + *) die "pull request #$pull_number has unknown state '$pull_state'" ;; + esac + record_classification "$pull_number" "$head_oid" \ + "$current_labels" "$role" "$(build_label "$lane")" \ + "$phase" "$blocked" "$block_reason" + done <"$state_dir/plans" +} + +labels >"$state_dir/managed-labels" +: >"$state_dir/classifications" +snapshot_refs +classify_topics codex +classify_topics codex-unstable +classify_meta +apply_classifications diff --git a/.github/workflows/codex-pr-state.yml b/.github/workflows/codex-pr-state.yml new file mode 100644 index 00000000000000..6c446883988ed4 --- /dev/null +++ b/.github/workflows/codex-pr-state.yml @@ -0,0 +1,66 @@ +name: Codex pull request state + +on: + workflow_call: + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: codex-pull-request-state + cancel-in-progress: false + +jobs: + reconcile: + name: Reconcile Codex topic, plan, and controller labels + if: >- + github.repository == 'openai/git' && + github.ref == 'refs/heads/codex' + runs-on: ubuntu-24.04 + steps: + - name: Pin trusted meta + id: meta + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + sha=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/meta" \ + --jq .object.sha) + case "$sha" in + ''|*[!0-9a-f]*) exit 1 ;; + esac + test "${#sha}" = 40 + printf 'sha=%s\n' "$sha" >>"$GITHUB_OUTPUT" + + - name: Check out trusted meta + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + repository: ${{ github.repository }} + ref: ${{ steps.meta.outputs.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Fetch the source, output, and staging refs + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_META: ${{ steps.meta.outputs.sha }} + run: | + set -euo pipefail + basic=$(printf 'x-access-token:%s' "$GH_TOKEN" | + base64 | tr -d '\n') + git -c http.extraheader="AUTHORIZATION: basic $basic" \ + fetch --force --prune origin \ + '+refs/heads/*:refs/remotes/origin/*' + test "$(git rev-parse HEAD)" = "$EXPECTED_META" + test "$(git rev-parse refs/remotes/origin/meta)" = "$EXPECTED_META" + + - name: Reconcile derived pull request labels + env: + GH_TOKEN: ${{ github.token }} + EXPECTED_META: ${{ steps.meta.outputs.sha }} + run: | + set -euo pipefail + sh .github/workflows/codex-pr-state.sh \ + --expected-meta "$EXPECTED_META" diff --git a/t/t9905-codex-branch.sh b/t/t9905-codex-branch.sh index 29fb07bd809836..b15a5ed1d5f6c1 100755 --- a/t/t9905-codex-branch.sh +++ b/t/t9905-codex-branch.sh @@ -16,6 +16,8 @@ codex_publish=${CODEX_PUBLISH:-$codex_root/publish} codex_admission_workflow=$codex_root/.github/workflows/codex-admission.yml codex_plan_admission_workflow=$codex_root/.github/workflows/codex-plan-admission.yml codex_plan_propose_workflow=$codex_root/.github/workflows/codex-plan-propose.yml +codex_pr_state=$codex_root/.github/workflows/codex-pr-state.sh +codex_pr_state_workflow=$codex_root/.github/workflows/codex-pr-state.yml codex_bot_name='chatgpt-codex-connector[bot]' codex_bot_email='199175422+chatgpt-codex-connector[bot]@users.noreply.github.com' @@ -83,6 +85,13 @@ write_reviewed_automation_workflow () { sed '1,2d;$d' | sed '$d;s/^\t//' >"$output" } +write_previous_pinned_reviewed_automation_workflow () { + output=$1 && + write_reviewed_automation_workflow "$output" && + sed '/^ pr_state:$/,$d' "$output" >"$output.previous" && + mv "$output.previous" "$output" +} + install_reviewed_automation_topic () { topic=${1:-aa/codex/automation} && style=${2:-current} && @@ -96,6 +105,10 @@ install_reviewed_automation_topic () { write_stable_reviewed_automation_workflow \ .github/workflows/codex.yml ;; + pinned-previous) + write_previous_pinned_reviewed_automation_workflow \ + .github/workflows/codex.yml + ;; *) return 1 ;; esac && git add .github/workflows/codex.yml && @@ -436,6 +449,327 @@ install_admission_gh () { chmod +x "$directory/gh" } +install_pr_state_gh () { + directory=$1 && + mkdir -p "$directory" && + cat >"$directory/gh" <<-'EOF' && + #!/bin/sh + + set -eu + + command=${1:-} + shift + case "$command" in + pr) + action=${1:-} + shift + case "$action" in + list) + base= + while test $# -gt 0 + do + case "$1" in + --base) base=$2; shift 2 ;; + *) shift ;; + esac + done + case "$base" in + codex|codex-unstable) + cat "$FAKE_PR_STATE_DATA/$base.json" + ;; + meta) cat "$FAKE_PR_STATE_DATA/plans.json" ;; + *) exit 91 ;; + esac + ;; + view) + printf '%s\n' "${FAKE_PR_STATE_REVIEW_DECISION:-APPROVED}" + ;; + *) exit 92 ;; + esac + ;; + api) + method=GET + endpoint= + filter= + label= + while test $# -gt 0 + do + case "$1" in + --hostname) shift 2 ;; + --method) method=$2; shift 2 ;; + --jq) filter=$2; shift 2 ;; + --paginate) shift ;; + -f|-F) + case "$2" in + name=*|labels\[\]=*) label=${2#*=} ;; + esac + shift 2 + ;; + graphql|repos/*) endpoint=$1; shift ;; + *) shift ;; + esac + done + case "$endpoint" in + graphql) + printf '%s\t%s\t%s\t%s\t%s\n' \ + "$FAKE_PR_STATE_META" "$FAKE_PR_STATE_STABLE" \ + "$FAKE_PR_STATE_UNSTABLE" \ + "${FAKE_PR_STATE_STABLE_STAGE:--}" \ + "${FAKE_PR_STATE_UNSTABLE_STAGE:--}" + ;; + repos/openai/git/labels\?*) + cat "$FAKE_PR_STATE_DATA/repository-labels" + ;; + repos/openai/git/labels) + test "$method" = POST || exit 93 + printf 'CREATE\t%s\n' "$label" \ + >>"$FAKE_PR_STATE_LOG" + ;; + repos/openai/git/pulls/*/reviews\?*) + pull_number=${endpoint#repos/openai/git/pulls/} + pull_number=${pull_number%%/*} + head=$(jq -r --arg number "$pull_number" ' + .[] | select((.number | tostring) == $number) | + .headRefOid + ' "$FAKE_PR_STATE_DATA/codex.json" \ + "$FAKE_PR_STATE_DATA/codex-unstable.json") + printf 'reviewer\tAPPROVED\t%s\tMEMBER\n' \ + "${FAKE_PR_STATE_REVIEW_SHA:-$head}" + ;; + repos/openai/git/pulls/*) + pull_number=${endpoint#repos/openai/git/pulls/} + if test "$filter" = .head.sha + then + if test -n "${FAKE_PR_STATE_MOVED_HEAD:-}" + then + printf '%s\n' "$FAKE_PR_STATE_MOVED_HEAD" + else + jq -r --arg number "$pull_number" ' + .[] | + select((.number | tostring) == $number) | + .headRefOid + ' "$FAKE_PR_STATE_DATA/codex.json" \ + "$FAKE_PR_STATE_DATA/codex-unstable.json" \ + "$FAKE_PR_STATE_DATA/plans.json" + fi + else + jq -r --arg number "$pull_number" ' + .[] | + select((.number | tostring) == $number) | + ["open", (.isDraft | tostring), .baseRefName, + .headRepository.nameWithOwner, .headRefName, + .headRefOid, "author"] | @tsv + ' "$FAKE_PR_STATE_DATA/codex.json" \ + "$FAKE_PR_STATE_DATA/codex-unstable.json" + fi + ;; + repos/openai/git/issues/*/labels*) + pull_number=${endpoint#repos/openai/git/issues/} + pull_number=${pull_number%%/*} + case "$method" in + POST) printf 'ADD\t%s\t%s\n' "$pull_number" "$label" ;; + DELETE) + removed=${endpoint##*/} + printf 'REMOVE\t%s\t%s\n' "$pull_number" "$removed" + ;; + *) exit 94 ;; + esac >>"$FAKE_PR_STATE_LOG" + ;; + *) + printf 'unexpected gh endpoint: %s\n' "$endpoint" >&2 + exit 95 + ;; + esac + ;; + *) exit 96 ;; + esac + EOF + chmod +x "$directory/gh" +} + +setup_pr_state_fixture () { + fixture=$1 && + test_create_repo "$fixture" && + ( + cd "$fixture" && + write base tracked && + git add tracked && + git commit -m "pull request state base" && + output=$(git rev-parse HEAD) && + git branch codex "$output" && + git branch codex-unstable "$output" && + git switch -c aa/codex/stable master && + write stable stable-file && + git add stable-file && + git commit -m "stable topic source" && + stable_tip=$(git rev-parse HEAD) && + git switch -c bb/codex/preview-unstable master && + write preview preview-file && + git add preview-file && + git commit -m "unstable topic source" && + unstable_tip=$(git rev-parse HEAD) && + git switch -c meta master && + cat >codex.plan <<-EOF && + [plan] + version = 1 + lane = codex + topic = refs/heads/aa/codex/stable + [branch "aa/codex/stable"] + source-tip = $stable_tip + EOF + cat >codex-unstable.plan <<-EOF && + [plan] + version = 1 + lane = codex-unstable + topic = refs/heads/bb/codex/preview-unstable + [branch "bb/codex/preview-unstable"] + source-tip = $unstable_tip + EOF + cat >codex.config <<-EOF && + [codex] + version = 3 + output-tip = $output + [codex-unstable] + output-tip = $output + EOF + git add codex.plan codex-unstable.plan codex.config && + git commit -m "meta: record desired pull request state" && + mkdir pr-state-data && + jq -n --arg head "$stable_tip" \ + '[{number:28,isDraft:false, + baseRefName:"codex", + headRefName:"aa/codex/stable",headRefOid:$head, + headRepository:{nameWithOwner:"openai/git"}, + reviewDecision:"APPROVED",labels:[]}]' \ + >pr-state-data/codex.json && + jq -n --arg head "$unstable_tip" \ + '[{number:21,isDraft:false, + baseRefName:"codex-unstable", + headRefName:"bb/codex/preview-unstable",headRefOid:$head, + headRepository:{nameWithOwner:"openai/git"}, + reviewDecision:"APPROVED",labels:[]}]' \ + >pr-state-data/codex-unstable.json && + printf '[]\n' >pr-state-data/plans.json && + printf '%s\n' kind:review-only kind:auto-plan \ + kind:plan-policy kind:controller \ + build:codex-stable build:codex-unstable \ + build:codex-controller codex:draft codex:needs-review \ + codex:ready \ + codex:awaiting-plan codex:planned codex:staged \ + codex:integrated codex:superseded codex:blocked \ + >pr-state-data/repository-labels && + : >pr-state-data/mutations && + install_pr_state_gh "$PWD/pr-state-bin" + ) +} + +run_pr_state_fixture () ( + fixture=$1 + shift + cd "$fixture" || exit 1 + stable_stage=$(git rev-parse --verify refs/heads/codex-staging \ + 2>/dev/null || printf '%s' -) + unstable_stage=$(git rev-parse --verify \ + refs/heads/codex-unstable-staging \ + 2>/dev/null || printf '%s' -) + env PATH="$PWD/pr-state-bin:$PATH" \ + FAKE_PR_STATE_DATA="$PWD/pr-state-data" \ + FAKE_PR_STATE_LOG="$PWD/pr-state-data/mutations" \ + FAKE_PR_STATE_META="$(git rev-parse meta)" \ + FAKE_PR_STATE_STABLE="$(git rev-parse codex)" \ + FAKE_PR_STATE_UNSTABLE="$(git rev-parse codex-unstable)" \ + FAKE_PR_STATE_STABLE_STAGE="$stable_stage" \ + FAKE_PR_STATE_UNSTABLE_STAGE="$unstable_stage" \ + sh "$codex_pr_state" "$@" +) + +stage_pr_state_fixture () ( + fixture=$1 + cd "$fixture" || exit 1 + meta=$(git rev-parse meta) && + output=$(git rev-parse codex) && + stable_tip=$(git rev-parse aa/codex/stable) && + unstable_tip=$(git rev-parse bb/codex/preview-unstable) && + stable_tree=$(git rev-parse "$stable_tip^{tree}") && + stable_stage=$(make_test_integration aa/codex/stable \ + "$stable_tip" "$output" "$stable_tree") && + git update-ref refs/heads/codex-staging "$stable_stage" && + unstable_tree=$(git merge-tree --write-tree \ + "$stable_stage" "$unstable_tip") && + rewritten=$(printf '%s\n' "replay preview onto staged stable" | + git -c commit.gpgSign=false commit-tree "$unstable_tree" \ + -p "$stable_stage") && + unstable_stage=$(make_test_unstable_integration \ + bb/codex/preview-unstable "$rewritten" "$stable_stage" \ + "$unstable_tree") && + git update-ref refs/heads/codex-unstable-staging \ + "$unstable_stage" && + stable_plan=$(git rev-parse "$meta:codex.plan") && + unstable_plan=$(git rev-parse "$meta:codex-unstable.plan") && + git switch --detach "$meta" && + cat >codex.config <<-EOF && + [codex] + version = 3 + output-tip = $stable_stage + applied-plan = $stable_plan + [codex-unstable] + output-tip = $unstable_stage + applied-plan = $unstable_plan + [branch "aa/codex/stable"] + source-tip = $stable_tip + [branch "bb/codex/preview-unstable"] + source-tip = $unstable_tip + EOF + git add codex.config && + git commit -m "meta: record staged source provenance" && + candidate_meta=$(git rev-parse HEAD) && + git switch meta && + printf 'controller\trefs/heads/meta\t%s\n' "$meta" >inputs && + { + printf 'refs/heads/meta\t%s\t%s\n' "$meta" \ + "$candidate_meta" && + printf 'refs/heads/codex\t%s\t%s\n' "$output" \ + "$stable_stage" && + printf 'refs/heads/codex-unstable\t%s\t%s\n' "$output" \ + "$unstable_stage" + } >updates +) + +write_pr_state_plan_fixture () ( + fixture=$1 + cd "$fixture" || exit 1 + meta=$(git rev-parse meta) && + stable_tip=$(git rev-parse aa/codex/stable) && + unstable_tip=$(git rev-parse bb/codex/preview-unstable) && + jq -n --arg head "$meta" --arg stable "$stable_tip" \ + --arg other "$unstable_tip" ' + def body($action; $source): + "Bot-generated pinned plan transition.\n\n" + + "- Lane: `codex`\n" + + "- Action: `" + $action + "`\n" + + "- Topic: `refs/heads/aa/codex/stable`\n" + + (if $source == "" then "" + else "- Source tip: `" + $source + "`\n" end); + def failure: + [{name:"Codex plan admission / Verify pinned manifest", + conclusion:"FAILURE"}]; + [ + {number:90,state:"MERGED",headRefName:"codex-plan/merged", + headRefOid:$head,body:body("add";$stable),labels:[], + reviewDecision:"APPROVED",statusCheckRollup:[]}, + {number:91,state:"CLOSED",headRefName:"codex-plan/replaced", + headRefOid:$head,body:body("add";$stable),labels:[], + reviewDecision:"REVIEW_REQUIRED",statusCheckRollup:failure}, + {number:92,state:"OPEN",headRefName:"codex-plan/blocked", + headRefOid:$head,body:body("alter";$other),labels:[], + reviewDecision:"REVIEW_REQUIRED",statusCheckRollup:failure}, + {number:93,state:"OPEN",headRefName:"codex-plan/remove", + headRefOid:$head,body:body("remove";""),labels:[], + reviewDecision:"REVIEW_REQUIRED",statusCheckRollup:[]} + ] + ' >pr-state-data/plans.json +) + install_admission_gate_gh () { directory=$1 && mkdir -p "$directory" && @@ -1601,6 +1935,25 @@ test_expect_success 'plan admission checks pinned topic heads from trusted meta' test_grep "meta changed while Codex plan admission was running" \ "$codex_plan_admission_workflow" ' + +test_expect_success 'pull request labels run from pinned meta without publisher credentials' ' + test_path_is_file "$codex_pr_state" && + test_path_is_file "$codex_pr_state_workflow" && + sh -n "$codex_pr_state" && + test_grep " workflow_call:" "$codex_pr_state_workflow" && + test_grep "github.ref == .refs/heads/codex." \ + "$codex_pr_state_workflow" && + test_grep "issues: write" "$codex_pr_state_workflow" && + test_grep "pull-requests: write" "$codex_pr_state_workflow" && + test_grep "steps.meta.outputs.sha" "$codex_pr_state_workflow" && + test_grep "expected-meta" "$codex_pr_state_workflow" && + ! grep -E "contents: write|environment:|PRIVATE_KEY|git push" \ + "$codex_pr_state_workflow" && + test_grep "pr_state:" "$codex_branch" && + test_grep "codex-pr-state.yml@meta" "$codex_branch" && + test_grep "reconcile-pr-state" "$codex_branch" +' + test_expect_success 'dual-lane release gate selects only the exact published output' ' write_dual_guarded_release_workflow \ "$TRASH_DIRECTORY/codex-release.yml" && @@ -2190,8 +2543,8 @@ test_expect_success 'topics cannot change the convenience wrappers' ' ' test_expect_success 'the reviewed automation trampoline cannot be downgraded' ' - for direction in upgrade stable-upgrade downgrade \ - stable-downgrade dual-downgrade + for direction in upgrade stable-upgrade label-upgrade downgrade \ + stable-downgrade dual-downgrade label-downgrade do fixture="automation-$direction" && git init --bare "$fixture.git" && @@ -2214,7 +2567,11 @@ test_expect_success 'the reviewed automation trampoline cannot be downgraded' ' write_stable_reviewed_automation_workflow \ .github/workflows/codex.yml ;; - downgrade|dual-downgrade) + label-upgrade) + write_previous_pinned_reviewed_automation_workflow \ + .github/workflows/codex.yml + ;; + downgrade|dual-downgrade|label-downgrade) write_reviewed_automation_workflow \ .github/workflows/codex.yml ;; @@ -2225,7 +2582,7 @@ test_expect_success 'the reviewed automation trampoline cannot be downgraded' ' git branch meta master && install_meta_state meta master codex && case "$direction" in - upgrade|stable-upgrade) + upgrade|stable-upgrade|label-upgrade) write_reviewed_automation_workflow \ .github/workflows/codex.yml ;; @@ -2237,6 +2594,10 @@ test_expect_success 'the reviewed automation trampoline cannot be downgraded' ' write_stable_reviewed_automation_workflow \ .github/workflows/codex.yml ;; + label-downgrade) + write_previous_pinned_reviewed_automation_workflow \ + .github/workflows/codex.yml + ;; esac && git add .github/workflows/codex.yml && git commit -m "change automation generation" && @@ -2247,7 +2608,8 @@ test_expect_success 'the reviewed automation trampoline cannot be downgraded' ' cd "$fixture-runner" && fetch_all && if test "$direction" = upgrade || - test "$direction" = stable-upgrade + test "$direction" = stable-upgrade || + test "$direction" = label-upgrade then sh "$codex_branch" rewrite --remote origin \ --base master --codex codex \ @@ -2274,6 +2636,38 @@ test_expect_success 'the reviewed automation trampoline cannot be downgraded' ' done ' +test_expect_success 'the deployed pinned trampoline remains valid during the label rollout' ' + git init --bare previous-pinned-automation.git && + test_create_repo previous-pinned-automation-source && + ( + cd previous-pinned-automation-source && + git remote add origin ../previous-pinned-automation.git && + write base tracked && + git add tracked && + install_rerere_train && + git commit -m "previous pinned automation base" && + install_reviewed_automation_topic aa/codex/automation \ + pinned-previous && + git branch codex "$automation_codex_tip" && + git branch meta master && + install_pinned_meta_state meta master codex && + git push origin --all + ) && + git clone previous-pinned-automation.git \ + previous-pinned-automation-runner && + ( + cd previous-pinned-automation-runner && + fetch_all && + sh "$codex_branch" rewrite --remote origin \ + --base master --codex codex --require-automation \ + --result result --updates updates \ + --inputs inputs --failure failure && + git show "$(cat result):.github/workflows/codex.yml" \ + >candidate-workflow && + ! grep -F "pr_state:" candidate-workflow + ) +' + test_expect_success 'published release provenance gates cannot be removed' ' for change in publication version do @@ -9987,4 +10381,183 @@ test_expect_success 'one-shot release recovery pins only the exact merged source ) ' +test_expect_success 'pull request state follows the planned head and effective approval' ' + setup_pr_state_fixture pr-state-head && + run_pr_state_fixture pr-state-head --dry-run >planned.out && + printf "#28\tkind:review-only\tbuild:codex-stable\tcodex:planned\n#21\tkind:review-only\tbuild:codex-unstable\tcodex:planned\n" \ + >planned.expect && + test_cmp planned.expect planned.out && + test_must_be_empty pr-state-head/pr-state-data/mutations && + old_head=$(git -C pr-state-head rev-parse aa/codex/stable) && + ( + cd pr-state-head && + git switch aa/codex/stable && + write advanced stable-file && + git add stable-file && + git commit -m "advance reviewed topic source" && + new_head=$(git rev-parse HEAD) && + git switch meta && + jq --arg head "$new_head" ".[].headRefOid = \$head" \ + pr-state-data/codex.json >pr-state-data/advanced.json && + mv pr-state-data/advanced.json pr-state-data/codex.json + ) && + FAKE_PR_STATE_REVIEW_SHA="$old_head" \ + run_pr_state_fixture pr-state-head --dry-run >stale.out && + test_grep "#28.*build:codex-stable.*codex:awaiting-plan" stale.out && + FAKE_PR_STATE_REVIEW_DECISION=REVIEW_REQUIRED \ + run_pr_state_fixture pr-state-head --dry-run >unapproved.out && + test_grep "#28.*build:codex-stable.*codex:needs-review" \ + unapproved.out && + new_head=$(git -C pr-state-head rev-parse aa/codex/stable) && + FAKE_PR_STATE_REVIEW_SHA="$new_head" \ + run_pr_state_fixture pr-state-head --dry-run >approved.out && + test_grep "#28.*build:codex-stable.*codex:awaiting-plan" approved.out && + test_must_be_empty pr-state-head/pr-state-data/mutations +' + +test_expect_success 'every open Codex pull request receives one exact classification' ' + setup_pr_state_fixture pr-state-inventory && + ( + cd pr-state-inventory && + stable_tip=$(git rev-parse aa/codex/stable) && + unstable_tip=$(git rev-parse bb/codex/preview-unstable) && + meta=$(git rev-parse meta) && + jq --arg head "$stable_tip" ". += [{ + number:67,isDraft:true,baseRefName:\"codex\", + headRefName:\"aa/codex/perf-wip\",headRefOid:\$head, + headRepository:{nameWithOwner:\"openai/git\"}, + reviewDecision:\"\",labels:[]}]" \ + pr-state-data/codex.json >pr-state-data/updated.json && + mv pr-state-data/updated.json pr-state-data/codex.json && + jq --arg head "$unstable_tip" ". += [{ + number:66,isDraft:true,baseRefName:\"codex-unstable\", + headRefName:\"long-owner/codex/feedback-fixes\", + headRefOid:\$head, + headRepository:{nameWithOwner:\"openai/git\"}, + reviewDecision:\"\",labels:[]}]" \ + pr-state-data/codex-unstable.json \ + >pr-state-data/updated.json && + mv pr-state-data/updated.json \ + pr-state-data/codex-unstable.json && + jq -n --arg head "$meta" "[ + {number:42,state:\"OPEN\",isDraft:true, + headRefName:\"aa/codex-controller-draft\", + headRefOid:\$head,body:\"\",labels:[], + reviewDecision:\"\",mergeStateStatus:\"UNKNOWN\", + statusCheckRollup:[]}, + {number:71,state:\"OPEN\",isDraft:false, + headRefName:\"aa/codex-controller-ready\", + headRefOid:\$head,body:\"\",labels:[], + reviewDecision:\"APPROVED\",mergeStateStatus:\"CLEAN\", + statusCheckRollup:[]}, + {number:72,state:\"OPEN\",isDraft:false, + headRefName:\"aa/codex-controller-conflict\", + headRefOid:\$head,body:\"\",labels:[], + reviewDecision:\"APPROVED\",mergeStateStatus:\"DIRTY\", + statusCheckRollup:[]} + ]" >pr-state-data/plans.json + ) && + run_pr_state_fixture pr-state-inventory --dry-run >inventory.out && + test_line_count = 7 inventory.out && + test_grep "#67.*kind:review-only.*build:codex-stable.*codex:draft.*codex:blocked.*blocked:invalid-topic-name" \ + inventory.out && + test_grep "#66.*kind:review-only.*build:codex-unstable.*codex:draft.*codex:blocked.*blocked:invalid-topic-name" \ + inventory.out && + test_grep "#42.*kind:controller.*build:codex-controller.*codex:draft" \ + inventory.out && + test_grep "#71.*kind:controller.*build:codex-controller.*codex:ready" \ + inventory.out && + test_grep "#72.*kind:controller.*build:codex-controller.*codex:ready.*codex:blocked.*blocked:merge-conflict" \ + inventory.out && + test_must_be_empty pr-state-inventory/pr-state-data/mutations && + ( + cd pr-state-inventory && + meta=$(git rev-parse meta) && + jq --arg head "$meta" ". += [{ + number:28,state:\"OPEN\",isDraft:true, + headRefName:\"aa/codex-duplicate-controller\", + headRefOid:\$head,body:\"\",labels:[],reviewDecision:\"\", + mergeStateStatus:\"UNKNOWN\",statusCheckRollup:[]}]" \ + pr-state-data/plans.json >pr-state-data/duplicate.json && + mv pr-state-data/duplicate.json pr-state-data/plans.json + ) && + test_expect_code 1 run_pr_state_fixture pr-state-inventory \ + >duplicate.out 2>duplicate.err && + test_grep "more than one classification" duplicate.err && + test_must_be_empty pr-state-inventory/pr-state-data/mutations +' + +test_expect_success 'rebased staging requires the frozen source and generation metadata' ' + setup_pr_state_fixture pr-state-staging && + stage_pr_state_fixture pr-state-staging && + run_pr_state_fixture pr-state-staging --dry-run >unverified.out && + test_grep "#28.*build:codex-stable.*codex:staged" unverified.out && + test_grep "#21.*build:codex-unstable.*codex:planned" \ + unverified.out && + run_pr_state_fixture pr-state-staging \ + --inputs inputs --updates updates --dry-run >verified.out && + test_grep "#21.*build:codex-unstable.*codex:staged" verified.out && + ( + cd pr-state-staging && + candidate_meta=$(awk -F "$(printf "\t")" \ + "\$1 == \"refs/heads/meta\" { print \$3 }" updates) && + stable_stage=$(git rev-parse codex-staging) && + unstable_stage=$(git rev-parse codex-unstable-staging) && + git switch --detach "$candidate_meta" && + git update-ref refs/heads/meta "$candidate_meta" && + git update-ref refs/heads/codex "$stable_stage" && + git update-ref refs/heads/codex-unstable "$unstable_stage" + ) && + run_pr_state_fixture pr-state-staging --dry-run >published.out && + test_grep "#28.*build:codex-stable.*codex:integrated" published.out && + test_grep "#21.*build:codex-unstable.*codex:integrated" \ + published.out +' + +test_expect_success 'automatic plans, obsolete proposals, and human policy remain distinct' ' + setup_pr_state_fixture pr-state-plans && + write_pr_state_plan_fixture pr-state-plans && + run_pr_state_fixture pr-state-plans --dry-run >plans.out && + test_grep "#90.*kind:auto-plan.*codex:planned" plans.out && + test_grep "#91.*kind:auto-plan.*codex:superseded" plans.out && + test_grep "#92.*kind:auto-plan.*codex:awaiting-plan.*codex:blocked" \ + plans.out && + test_grep "#93.*kind:plan-policy.*build:codex-stable.*codex:needs-review" \ + plans.out && + ! grep "#91.*codex:blocked" plans.out +' + +test_expect_success 'label reconciliation preserves unrelated labels and rejects moved heads' ' + setup_pr_state_fixture pr-state-mutations && + ( + cd pr-state-mutations && + jq ".[].labels = [{name:\"keep-me\"}, + {name:\"build:codex-unstable\"}, + {name:\"codex:needs-review\"}]" \ + pr-state-data/codex.json >pr-state-data/labeled.json && + mv pr-state-data/labeled.json pr-state-data/codex.json + ) && + wrong_head=$(git -C pr-state-mutations rev-parse master) && + FAKE_PR_STATE_MOVED_HEAD="$wrong_head" \ + run_pr_state_fixture pr-state-mutations >moved.out 2>moved.err && + test_grep "pull request #28: its head moved" moved.err && + test_must_be_empty pr-state-mutations/pr-state-data/mutations && + test_expect_code 1 run_pr_state_fixture pr-state-mutations \ + --expected-meta "$wrong_head" --dry-run \ + >wrong-meta.out 2>wrong-meta.err && + test_grep "meta moved" wrong-meta.err && + run_pr_state_fixture pr-state-mutations >reconciled.out && + test_grep "ADD.*28.*kind:review-only" \ + pr-state-mutations/pr-state-data/mutations && + test_grep "ADD.*28.*build:codex-stable" \ + pr-state-mutations/pr-state-data/mutations && + test_grep "ADD.*28.*codex:planned" \ + pr-state-mutations/pr-state-data/mutations && + test_grep "REMOVE.*28.*build%3Acodex-unstable" \ + pr-state-mutations/pr-state-data/mutations && + test_grep "REMOVE.*28.*codex%3Aneeds-review" \ + pr-state-mutations/pr-state-data/mutations && + ! grep -F keep-me pr-state-mutations/pr-state-data/mutations +' + test_done