From d69563ba265830ca4b6cdd9fe3bf4d9d34d3289d Mon Sep 17 00:00:00 2001 From: Taylor Blau Date: Sun, 9 Aug 2026 20:08:00 -0600 Subject: [PATCH] codex: reconcile reviewed topic and build state labels GitHub represents reviewed Codex topics, mechanical plan transitions, and ordinary controller changes as pull requests even though generated output branches cannot accept direct topic merges. Approved or obsolete requests therefore give reviewers little indication of what needs attention or which exact source head has reached a build. Derive role, build, and lifecycle labels from exact topic approvals, pinned plans, verified candidate metadata, and published output refs. Keep policy plans human-reviewed, preserve unrelated labels, and update staging and promotion labels without making presentation a release gate. Run reconciliation only from the pinned trusted controller. Accept the currently deployed pinned-plan trampoline until its automation topic can be upgraded, but reject downgrades once the label-aware version ships. Cover stale reviews, changed heads, rebased staging, superseded plans, and rollout compatibility in t9905. --- .github/CODEX.md | 39 +++ .github/workflows/codex-branch.sh | 62 +++- .github/workflows/codex-pr-state.sh | 492 ++++++++++++++++++++++++++ .github/workflows/codex-pr-state.yml | 66 ++++ t/t9905-codex-branch.sh | 504 ++++++++++++++++++++++++++- 5 files changed, 1157 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 aa1c6b6d614f19..0d249bb89e6cc9 100644 --- a/.github/CODEX.md +++ b/.github/CODEX.md @@ -57,6 +57,39 @@ topic head changes before that happens, the exact-head check fails. A new approval produces a new pin and a new plan transition. Once the plan merges, the immutable pin remains authoritative even if the source branch moves. +## Pull request labels + +Topic pull requests are review-only: approve them, but do not merge them. +Automatic add/alter plan pull requests are separate mechanical changes to +`meta`. Labels make that distinction and the current build state visible: + +- `kind:review-only` identifies a topic PR; `kind:auto-plan` identifies an + automatically admitted add/alter plan PR. Explicit remove/reorder plans + still require ordinary human review and never receive `kind:auto-plan`. +- `build:codex` and `build:codex-unstable` identify the affected build. +- Exactly one `codex:*` state identifies the current source head: + `codex:needs-review`, `codex:awaiting-plan`, `codex:planned`, + `codex:staged`, `codex:integrated`, or `codex:superseded`. +- `codex:blocked` is additional and appears only when the required plan + admission check has failed on an open, unsuperseded plan PR. + +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 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 @@ -149,6 +182,7 @@ default-branch trampoline: - dispatches ordinary refresh; - scans exact 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`. @@ -158,6 +192,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 12743516da525c..ca90774b3ce421 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" @@ -7966,6 +7999,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 \ @@ -8107,6 +8142,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 @@ -9524,6 +9564,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 @@ -9544,6 +9597,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' \ @@ -9561,6 +9615,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 @@ -9579,6 +9634,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." say "Generated commits identify $bot_name <$bot_email>; the push uses your configured origin credentials." } @@ -9698,6 +9755,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." say "Generated commits identify $bot_name <$bot_email>; the push uses your configured origin credentials." } @@ -9941,6 +10000,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..83b4facd9966e9 --- /dev/null +++ b/.github/workflows/codex-pr-state.sh @@ -0,0 +1,492 @@ +#!/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 + build:codex 0e8a16 Production Codex Git build + build:codex-unstable fbca04 Preview Codex Git build + codex:needs-review d93f0b Current topic head needs a qualifying review + 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 Required plan admission needs intervention + 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 +} + +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 + current_labels=$4 + 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" && return 0 + has_label "$current_labels" codex:staged +} + +exactly_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 the exact head of pull request #$pull_number" + fi + return 1 +} + +state_for_topic () { + lane=$1 + topic=$2 + source_tip=$3 + current_labels=$4 + 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" "$current_labels" + 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=build:$5 + desired_state=$6 + desired_blocked=$7 + + 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' + 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" +} + +reconcile_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 --arg repository "$repository" ' + .[] | + select(.headRepository.nameWithOwner == $repository) | + [(.number | tostring), (.isDraft | tostring), + .headRefName, .headRefOid, (.reviewDecision // "-"), + ([.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 \ + review_decision current_labels + do + test -n "$pull_number" || continue + case "$topic:$lane" in + ??/codex/*-unstable:codex-unstable) ;; + ??/codex/*:codex) + case "$topic" in *-unstable) continue ;; esac + ;; + *) continue ;; + esac + case "$topic" in + *-wip|*-stale|??/codex/*/*) continue ;; + esac + is_full_oid "$source_tip" || + die "pull request #$pull_number has an invalid head SHA" + state_for_topic "$lane" "$topic" "$source_tip" \ + "$current_labels" + if test -z "$computed_state" + then + computed_state=codex:needs-review + if test "$draft" = false && + test "$review_decision" = APPROVED && + exactly_approved "$pull_number" "$lane" \ + "$topic" "$source_tip" + then + computed_state=codex:awaiting-plan + fi + fi + sync_labels "$pull_number" "$source_tip" "$current_labels" \ + kind:review-only "$lane" "$computed_state" false + done <"$state_dir/$lane-topics" +} + +reconcile_plans () { + gh pr list --repo "$repository" --state all --base meta \ + --limit 100 \ + --json number,state,headRefName,headRefOid,body,labels,reviewDecision,statusCheckRollup \ + >"$state_dir/plans.json" || + die "could not list Codex plan pull requests" + jq -r ' + def field($name): + [(.body // "" | split("\n")[]) | + select(startswith("- " + $name + ": `")) | + ltrimstr("- " + $name + ": `") | + rtrimstr("`")] | .[0] // ""; + .[] | + select(.headRefName | startswith("codex-plan/")) | + [(.number | tostring), .state, .headRefOid, + field("Lane"), field("Action"), + (field("Topic") | sub("^refs/heads/"; "")), + (field("Source tip") | if . == "" then "-" else . end), + ([.labels[].name] | @json), + (any(.statusCheckRollup[]?; + (.name // .context // "") == + "Codex plan admission / Verify pinned manifest" and + (.conclusion // .state // "") == "FAILURE") | tostring), + (.reviewDecision // "-")] | @tsv + ' "$state_dir/plans.json" >"$state_dir/plans" || + die "could not parse Codex plan pull requests" + while IFS="$tab" read -r pull_number pull_state head_oid lane action \ + topic source_tip current_labels admission_failed review_decision + do + test -n "$pull_number" || continue + case "$lane" in + codex) ;; + codex-unstable) test "$unstable" != - || continue ;; + *) continue ;; + esac + case "$action" in + add|alter) role=kind:auto-plan ;; + remove|reorder) role=- ;; + *) continue ;; + esac + blocked=false + case "$pull_state" in + CLOSED) phase=codex:superseded ;; + MERGED) + phase=codex:superseded + if test "$source_tip" != - + then + state_for_topic "$lane" "$topic" "$source_tip" \ + "$current_labels" + test -z "$computed_state" || phase=$computed_state + fi + ;; + OPEN) + phase=codex:awaiting-plan + if test "$role" = - && + test "$review_decision" != APPROVED + then + phase=codex:needs-review + elif 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 + fi + ;; + *) continue ;; + esac + sync_labels "$pull_number" "$head_oid" "$current_labels" \ + "$role" "$lane" "$phase" "$blocked" + done <"$state_dir/plans" +} + +labels >"$state_dir/managed-labels" +snapshot_refs +ensure_labels +reconcile_topics codex +reconcile_topics codex-unstable +reconcile_plans diff --git a/.github/workflows/codex-pr-state.yml b/.github/workflows/codex-pr-state.yml new file mode 100644 index 00000000000000..d32ce37f5cbf87 --- /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 reviewed topic and plan 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 b9f9cb20c553c0..326e76803216e7 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,324 @@ 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 \ + build:codex build:codex-unstable codex:needs-review \ + 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 +1932,25 @@ test_expect_success 'plan admission checks exact reviewed heads from trusted met 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 +2540,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 +2564,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 +2579,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 +2591,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 +2605,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 +2633,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 @@ -9257,4 +9648,107 @@ test_expect_success 'one-shot release recovery pins only the exact merged source ) ' +test_expect_success 'pull request state follows the exact planned and approved source head' ' + 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\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.*codex:needs-review" stale.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.*codex:awaiting-plan" approved.out && + test_must_be_empty pr-state-head/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.*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.*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.*build:codex.*codex:needs-review" plans.out && + ! grep "#93.*kind:auto-plan" 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" \ + 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