Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/upstream-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
downstream: ${{ steps.observe.outputs.downstream_sha }}
blocking_pr: ${{ steps.observe.outputs.blocking_pr_number }}
blocking_head: ${{ steps.observe.outputs.blocking_pr_head_sha }}
existing_pr: ${{ steps.observe.outputs.existing_pr_number }}
existing_branch: ${{ steps.observe.outputs.existing_pr_branch }}
existing_head: ${{ steps.observe.outputs.existing_pr_head_sha }}
steps:
- name: Checkout trusted workflow implementation
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
Expand Down Expand Up @@ -98,11 +101,17 @@ jobs:
EXPECTED_DOWNSTREAM: ${{ needs.observe.outputs.downstream }}
EXPECTED_BLOCKING_PR: ${{ needs.observe.outputs.blocking_pr }}
EXPECTED_BLOCKING_HEAD: ${{ needs.observe.outputs.blocking_head }}
EXPECTED_EXISTING_PR: ${{ needs.observe.outputs.existing_pr }}
EXPECTED_EXISTING_BRANCH: ${{ needs.observe.outputs.existing_branch }}
EXPECTED_EXISTING_HEAD: ${{ needs.observe.outputs.existing_head }}
CONFIGURED_SCHEDULE: ${{ github.event.schedule || 'manual' }}
run: >-
python3 scripts/hosted_upstream.py --publish
--expected-upstream "$EXPECTED_UPSTREAM" --expected-downstream "$EXPECTED_DOWNSTREAM"
--expected-blocking-pr "$EXPECTED_BLOCKING_PR" --expected-blocking-head "$EXPECTED_BLOCKING_HEAD"
--expected-existing-pr "$EXPECTED_EXISTING_PR"
--expected-existing-branch "$EXPECTED_EXISTING_BRANCH"
--expected-existing-head "$EXPECTED_EXISTING_HEAD"
--output "$RUNNER_TEMP/outcome.json"
- name: Save complete publication outcome
if: always()
Expand Down
5 changes: 3 additions & 2 deletions docs/CODEX_HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,9 @@ not current instruction.

- Public guidance is versioned in the repository. Optional Wiki publication remains a separate
operator action and cannot replace engineering authority.
- Existing upstream Draft PR #58 is an expected authenticated human-review waiting state. Do not
close, merge, rewrite, or classify it as a platform failure merely to make the queue empty.
- Current upstream Draft PR #106 is the human-controlled same-episode candidate. Reused-Draft
reconciliation requires fresh exact-main hosted evidence and preserves the Draft remote head
until the reviewed two-layer merge is ready; do not close, rewrite, or force-update it.

## Non-obvious invariants and negative knowledge

Expand Down
8 changes: 8 additions & 0 deletions docs/PREPARE_PR.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ Preserved upstream REVIEW/conflict publication is deliberately excluded. Prepare
and updates the same Draft but neither enables auto-merge nor changes Draft readiness. Human semantic
review and merge/reject authority remain mandatory for that path.

An upstream Draft reconciled after unrelated `main` movement uses the separate
`-PreserveReconciledUpstreamMerge` contract. It requires exact original candidate, pre-publication
remote Draft head, current main, reconciliation commit, final upstream parents, and reviewed final
tree identities. The reconciliation is either exact `[Draft head, current main]` or current main is
already contained by the Draft head; the final merge remains exact `[reconciliation, recorded
upstream]`. Main/head drift refuses before the same no-force fast-forward push, and the Draft remains
excluded from auto-merge.

Public fork PRs are also outside prepare-pr's auto-merge authority. The script requires an exact
downstream-owned PR head, so a foreign/fork head cannot pass authentication and Mosaic automation
does not arm it. A contributor without repository write permission cannot independently enable
Expand Down
16 changes: 14 additions & 2 deletions docs/UPSTREAM_SYNC.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,15 @@ This parent shape lets publication fast-forward the same Draft without rewriting
tree must equal the reviewed index and contain neither blocked context nor conflict markers.
Ready-for-review, CI, and merge/reject remain explicit human steps.

When unrelated Mosaic main movement leaves an existing same-episode Draft on an older baseline,
the Draft is actionable only after a fresh successful Upstream Synchronization outcome on exact
current `main` binds the same episode to the exact PR number, branch, and live head. The resolver
keeps the original candidate evidence separate from this reuse proof. Starting from authenticated
Draft head `C`, it reviews an exact `B = merge(C, M)` with parents `[C, M]`; if `M` is already an
ancestor of `C`, `B = C`. Only then does it resolve the original recorded upstream `U`, producing
`R` with exact parents `[B, U]`. Reconciliation conflicts and upstream semantic conflicts are shown
as separate stages. The remote remains at `C` until one reviewed, no-force fast-forward `C -> R`.

The schedule `0 6,15,21 * * *` is UTC: approximately 08:00/17:00/23:00 Bucharest in winter
and 09:00/18:00/00:00 in summer. GitHub cron does not follow DST and may start late; evidence
separates configured cron from actual observation time. Complete observations retain excluded
Expand Down Expand Up @@ -421,8 +430,11 @@ already exist. Exact attention-path overlap, shared semantic production/ownershi
ancestry and downstream observation baselines form a deterministic dependency graph. A proven predecessor is `Ready for resolution`; a dependent is
`Waiting on PR #N`; unrelated candidates are `Independent`. Closed/satisfied candidates are
`Superseded`. Incomparable ancestry or evidence observed against an older current-main baseline is
`Dependency ambiguous` and cannot be selected. The next hosted observation must recompute stale
scope/priority against authoritative main; the local helper never rebases or overwrites a Draft.
`Dependency ambiguous` and cannot be selected. A stale same-episode Draft becomes
`Ready for current-main reconciliation` only when a retained fresh outcome authenticates exact
current main plus the exact live Draft identity. Missing/expired evidence, disagreement, head or
main drift, non-descendant Draft history, or unexplained Draft scope remains ambiguous/refused. The
local helper never rebases or overwrites a Draft.
The current hosted I06 publisher still retains its earlier one-open-sync-PR guard, so normal hosted
operation does not yet create concurrent independent candidates. Changing that hosted creation
policy is a separate explicit follow-up, not part of this local operator helper.
Expand Down
44 changes: 37 additions & 7 deletions scripts/hosted_upstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,8 @@ def technical_evidence(observation):
"candidate_parents", "classification_range_count", "ownership_counts", "review_paths",
"conflict_paths", "clean_path_count", "blocking_pr_number", "blocking_pr_url",
"blocking_pr_head_sha", "blocking_pr_branch", "blocking_upstream_sha",
"blocking_downstream_sha",
"blocking_downstream_sha", "existing_pr_number", "existing_pr_branch",
"existing_pr_head_sha",
"configured_schedule_utc", "observed_at", "run_url")
if observation.get(key) is not None
}, indent=2, ensure_ascii=True)
Expand Down Expand Up @@ -773,7 +774,10 @@ def existing_candidate(git, pulls, observation, candidate, policy_version):
if observation.get("episode_id") and not same[0].get("draft"):
raise Blocked("Attention-required candidate is no longer Draft; preserve the human PR decision.")
observation.update(outcome="existing_draft_pr" if same[0].get("draft") else "existing_pr",
pr_url=same[0]["html_url"], pr_number=same[0]["number"])
pr_url=same[0]["html_url"], pr_number=same[0]["number"],
existing_pr_number=same[0]["number"],
existing_pr_branch=same[0]["head"]["ref"],
existing_pr_head_sha=same[0]["head"]["sha"])
return True
if observation.get("episode_id"):
episode = [pull for pull in pulls if pull_episode(pull) == observation["episode_id"]]
Expand All @@ -785,7 +789,10 @@ def existing_candidate(git, pulls, observation, candidate, policy_version):
if not pull.get("draft"):
raise Blocked("The unresolved episode PR is no longer Draft; preserve the human PR decision.")
observation.update(outcome="existing_draft_pr", pr_url=pull["html_url"],
pr_number=pull["number"], existing_branch=pull["head"]["ref"])
pr_number=pull["number"], existing_branch=pull["head"]["ref"],
existing_pr_number=pull["number"],
existing_pr_branch=pull["head"]["ref"],
existing_pr_head_sha=pull["head"]["sha"])
return True
if episode:
raise Blocked("The unresolved episode has a closed PR decision; do not automatically reopen or recreate it.")
Expand Down Expand Up @@ -963,7 +970,8 @@ def inspect(git, github, observation, anchor=INITIAL_ANCHOR):


def publish(git, github, observation, expected_up, expected_down,
expected_blocking_pr="", expected_blocking_head=""):
expected_blocking_pr="", expected_blocking_head="", expected_existing_pr="",
expected_existing_branch="", expected_existing_head=""):
if (observation.get("upstream_sha"), observation.get("downstream_sha")) != (expected_up, expected_down):
raise Blocked("Refs changed between read and publish jobs; rerun to observe current inputs.")
expected_wait = bool(str(expected_blocking_pr).strip() or str(expected_blocking_head).strip())
Expand All @@ -978,10 +986,25 @@ def publish(git, github, observation, expected_up, expected_down,
return
if expected_wait:
raise Blocked("Blocking PR state changed between read and publish jobs; rerun safely.")
expected_existing = tuple(str(value).strip() for value in (
expected_existing_pr, expected_existing_branch, expected_existing_head
))
has_expected_existing = any(expected_existing)
if has_expected_existing and not all(expected_existing):
raise Blocked("Observe handoff contains an incomplete existing Draft identity.")
if observation["outcome"] in {"no_delta", "observed_excluded"}:
if has_expected_existing:
raise Blocked("Existing Draft state changed between read and publish jobs; rerun safely.")
return
if observation["outcome"] in {"existing_pr", "existing_draft_pr"}:
actual_existing = tuple(str(observation.get(key) or "") for key in (
"existing_pr_number", "existing_pr_branch", "existing_pr_head_sha"
))
if not has_expected_existing or actual_existing != expected_existing:
raise Blocked("Existing Draft state changed between read and publish jobs; rerun safely.")
return
if has_expected_existing:
raise Blocked("Existing Draft state changed between read and publish jobs; rerun safely.")
if observation["outcome"] not in {"ready", "review_required", "semantic_conflict"}:
raise Blocked("Observation is not a publishable candidate.")
token_present = bool(os.environ.get("SYNC_PUBLISH_TOKEN", "").strip())
Expand Down Expand Up @@ -1107,10 +1130,13 @@ def main():
parser.add_argument("--expected-downstream", default="")
parser.add_argument("--expected-blocking-pr", default="")
parser.add_argument("--expected-blocking-head", default="")
parser.add_argument("--expected-existing-pr", default="")
parser.add_argument("--expected-existing-branch", default="")
parser.add_argument("--expected-existing-head", default="")
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
raw_repository = os.environ.get("GITHUB_REPOSITORY", "")
o = {"schema_version": 1, "upstream_repo": UPSTREAM, "upstream_ref": "refs/heads/main",
o = {"schema_version": 2, "upstream_repo": UPSTREAM, "upstream_ref": "refs/heads/main",
"downstream_repo": raw_repository, "downstream_ref": "refs/heads/main",
"observed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"configured_schedule_utc": os.environ.get("CONFIGURED_SCHEDULE") or "manual",
Expand Down Expand Up @@ -1140,7 +1166,9 @@ def main():
inspect(git, github, o)
if args.publish:
publish(git, github, o, args.expected_upstream, args.expected_downstream,
args.expected_blocking_pr, args.expected_blocking_head)
args.expected_blocking_pr, args.expected_blocking_head,
args.expected_existing_pr, args.expected_existing_branch,
args.expected_existing_head)
except (Blocked, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as exc:
failed = True
operation_error = isinstance(exc, OperationError) or not isinstance(exc, Blocked)
Expand All @@ -1152,7 +1180,9 @@ def main():
if os.environ.get("GITHUB_OUTPUT"):
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as stream:
for key in ("outcome", "upstream_sha", "downstream_sha",
"blocking_pr_number", "blocking_pr_head_sha"):
"blocking_pr_number", "blocking_pr_head_sha",
"existing_pr_number", "existing_pr_branch",
"existing_pr_head_sha"):
stream.write(f"{key}={o.get(key, '')}\n")
if os.environ.get("GITHUB_STEP_SUMMARY"):
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as stream:
Expand Down
Loading
Loading