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