@opengsd/gsd-path 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +397 -0
- package/DOCS.md +341 -0
- package/FULL.md +475 -0
- package/GUIDE.md +23 -0
- package/HOOKS.md +273 -0
- package/LICENSE +21 -0
- package/MIGRATE.md +100 -0
- package/QUICK.md +146 -0
- package/README.md +280 -0
- package/RUNTIME.md +229 -0
- package/UPDATE.md +196 -0
- package/WORKFLOW.md +683 -0
- package/package.json +80 -0
- package/platforms/claude/dispatch.md +63 -0
- package/platforms/copilot/dispatch.md +61 -0
- package/platforms/cursor/agent.md +17 -0
- package/platforms/cursor/dispatch.md +63 -0
- package/platforms/grok/dispatch.md +61 -0
- package/platforms/kimi/dispatch.md +64 -0
- package/platforms/kiro/dispatch.md +58 -0
- package/platforms/opencode/dispatch.md +62 -0
- package/platforms/qwen/dispatch.md +60 -0
- package/platforms/shared-agents/dispatch.md +131 -0
- package/plugin.json +14 -0
- package/scripts/_common.py +180 -0
- package/scripts/archive_milestone.py +2265 -0
- package/scripts/bootstrap_repository.py +710 -0
- package/scripts/build_state.py +854 -0
- package/scripts/check_docs_audit.py +463 -0
- package/scripts/check_handoffs.py +1855 -0
- package/scripts/check_task_briefs.py +376 -0
- package/scripts/check_update.py +93 -0
- package/scripts/core_hook_gate.py +34 -0
- package/scripts/core_hook_settings.py +119 -0
- package/scripts/detect_project.py +1517 -0
- package/scripts/discussion_records.py +619 -0
- package/scripts/discussion_validate.py +1321 -0
- package/scripts/dispatch_driver.py +1812 -0
- package/scripts/git_guard.py +759 -0
- package/scripts/guard_hook.py +2124 -0
- package/scripts/install.mjs +3239 -0
- package/scripts/install.py +3008 -0
- package/scripts/integration.py +1433 -0
- package/scripts/isolation.py +3538 -0
- package/scripts/lean_verification.py +256 -0
- package/scripts/loop_run.py +837 -0
- package/scripts/migrate_core.py +169 -0
- package/scripts/pipeline_diagnose.py +715 -0
- package/scripts/pipeline_git.py +940 -0
- package/scripts/pipeline_state.py +2291 -0
- package/scripts/pipeline_undo.py +1039 -0
- package/scripts/promote_lookahead.py +415 -0
- package/scripts/review_findings.py +714 -0
- package/scripts/review_panel.py +553 -0
- package/scripts/skill-resources.json +340 -0
- package/scripts/state_checkpoint.py +1039 -0
- package/scripts/state_promote.py +743 -0
- package/scripts/status_runtime.py +122 -0
- package/scripts/sync_skill_resources.py +259 -0
- package/scripts/token_budget.py +146 -0
- package/scripts/wizard.mjs +179 -0
- package/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/BUILD.md +700 -0
- package/skills/gsd-path/DECIDE.md +115 -0
- package/skills/gsd-path/DEFINE.md +297 -0
- package/skills/gsd-path/DOCS-AUDIT.md +190 -0
- package/skills/gsd-path/INSPECT.md +167 -0
- package/skills/gsd-path/PLAN.md +411 -0
- package/skills/gsd-path/RESEARCH.md +159 -0
- package/skills/gsd-path/ROADMAP.md +215 -0
- package/skills/gsd-path/SHIP.md +412 -0
- package/skills/gsd-path/SKILL.md +471 -0
- package/skills/gsd-path/agents/openai.yaml +6 -0
- package/skills/gsd-path/references/codebase-mapper.md +52 -0
- package/skills/gsd-path/references/coder.md +90 -0
- package/skills/gsd-path/references/decider.md +36 -0
- package/skills/gsd-path/references/dispatch.md +131 -0
- package/skills/gsd-path/references/docs-auditor.md +60 -0
- package/skills/gsd-path/references/planner.md +104 -0
- package/skills/gsd-path/references/researcher.md +42 -0
- package/skills/gsd-path/references/reviewer.md +188 -0
- package/skills/gsd-path/references/roadmapper.md +48 -0
- package/skills/gsd-path/scripts/_common.py +180 -0
- package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
- package/skills/gsd-path/scripts/build_state.py +854 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path/scripts/check_update.py +93 -0
- package/skills/gsd-path/scripts/detect_project.py +1517 -0
- package/skills/gsd-path/scripts/discussion_records.py +619 -0
- package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path/scripts/integration.py +1433 -0
- package/skills/gsd-path/scripts/isolation.py +3538 -0
- package/skills/gsd-path/scripts/lean_verification.py +256 -0
- package/skills/gsd-path/scripts/loop_run.py +837 -0
- package/skills/gsd-path/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path/scripts/review_findings.py +714 -0
- package/skills/gsd-path/scripts/review_panel.py +553 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path/scripts/state_promote.py +743 -0
- package/skills/gsd-path/scripts/token_budget.py +146 -0
- package/skills/gsd-path/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/templates/answers.md +29 -0
- package/skills/gsd-path/templates/archive-manifest.md +37 -0
- package/skills/gsd-path/templates/charter.md +50 -0
- package/skills/gsd-path/templates/codebase.md +46 -0
- package/skills/gsd-path/templates/dialogue.md +21 -0
- package/skills/gsd-path/templates/docs-audit.md +69 -0
- package/skills/gsd-path/templates/evidence.md +24 -0
- package/skills/gsd-path/templates/final-review.md +27 -0
- package/skills/gsd-path/templates/gap-review.md +20 -0
- package/skills/gsd-path/templates/intent.md +98 -0
- package/skills/gsd-path/templates/loop.md +59 -0
- package/skills/gsd-path/templates/patch-findings.md +16 -0
- package/skills/gsd-path/templates/plan-panel.md +25 -0
- package/skills/gsd-path/templates/plan.md +89 -0
- package/skills/gsd-path/templates/repository.md +16 -0
- package/skills/gsd-path/templates/research-handoff.md +25 -0
- package/skills/gsd-path/templates/roadmap.md +38 -0
- package/skills/gsd-path/templates/skeptic.md +36 -0
- package/skills/gsd-path/templates/state.md +27 -0
- package/skills/gsd-path/templates/synthesis.md +36 -0
- package/skills/gsd-path/templates/task.md +74 -0
- package/skills/gsd-path/templates/wave-panel.md +25 -0
- package/skills/gsd-path/templates/wave-review.md +64 -0
- package/skills/gsd-path-build/SKILL.md +700 -0
- package/skills/gsd-path-build/agents/openai.yaml +6 -0
- package/skills/gsd-path-build/references/coder.md +90 -0
- package/skills/gsd-path-build/references/dispatch.md +131 -0
- package/skills/gsd-path-build/references/reviewer.md +188 -0
- package/skills/gsd-path-build/scripts/_common.py +180 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-build/scripts/build_state.py +854 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path-build/scripts/integration.py +1433 -0
- package/skills/gsd-path-build/scripts/isolation.py +3538 -0
- package/skills/gsd-path-build/scripts/loop_run.py +837 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-build/scripts/review_findings.py +714 -0
- package/skills/gsd-path-build/scripts/review_panel.py +553 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-build/scripts/state_promote.py +743 -0
- package/skills/gsd-path-build/scripts/token_budget.py +146 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-build/templates/skeptic.md +36 -0
- package/skills/gsd-path-build/templates/task.md +74 -0
- package/skills/gsd-path-build/templates/wave-panel.md +25 -0
- package/skills/gsd-path-build/templates/wave-review.md +64 -0
- package/skills/gsd-path-decide/SKILL.md +115 -0
- package/skills/gsd-path-decide/agents/openai.yaml +6 -0
- package/skills/gsd-path-decide/references/decider.md +36 -0
- package/skills/gsd-path-decide/references/dispatch.md +131 -0
- package/skills/gsd-path-decide/scripts/_common.py +180 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
- package/skills/gsd-path-decide/templates/synthesis.md +36 -0
- package/skills/gsd-path-define/SKILL.md +297 -0
- package/skills/gsd-path-define/agents/openai.yaml +6 -0
- package/skills/gsd-path-define/scripts/_common.py +180 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-define/scripts/isolation.py +3538 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-define/scripts/state_promote.py +743 -0
- package/skills/gsd-path-define/templates/charter.md +50 -0
- package/skills/gsd-path-define/templates/intent.md +98 -0
- package/skills/gsd-path-define/templates/state.md +27 -0
- package/skills/gsd-path-discuss/SKILL.md +162 -0
- package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
- package/skills/gsd-path-discuss/scripts/_common.py +180 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-discuss/templates/answers.md +29 -0
- package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
- package/skills/gsd-path-docs-audit/SKILL.md +190 -0
- package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
- package/skills/gsd-path-forensics/SKILL.md +39 -0
- package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
- package/skills/gsd-path-forensics/scripts/_common.py +180 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
- package/skills/gsd-path-inspect/SKILL.md +167 -0
- package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
- package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
- package/skills/gsd-path-inspect/references/dispatch.md +131 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
- package/skills/gsd-path-inspect/scripts/_common.py +180 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
- package/skills/gsd-path-inspect/templates/codebase.md +46 -0
- package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
- package/skills/gsd-path-inspect/templates/state.md +27 -0
- package/skills/gsd-path-loop/SKILL.md +98 -0
- package/skills/gsd-path-loop/agents/openai.yaml +6 -0
- package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
- package/skills/gsd-path-loop/scripts/_common.py +180 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
- package/skills/gsd-path-loop/templates/loop.md +59 -0
- package/skills/gsd-path-migrate/SKILL.md +134 -0
- package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
- package/skills/gsd-path-plan/SKILL.md +411 -0
- package/skills/gsd-path-plan/agents/openai.yaml +6 -0
- package/skills/gsd-path-plan/references/dispatch.md +131 -0
- package/skills/gsd-path-plan/references/planner.md +104 -0
- package/skills/gsd-path-plan/scripts/_common.py +180 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
- package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
- package/skills/gsd-path-plan/templates/plan.md +89 -0
- package/skills/gsd-path-plan/templates/task.md +74 -0
- package/skills/gsd-path-research/SKILL.md +159 -0
- package/skills/gsd-path-research/agents/openai.yaml +6 -0
- package/skills/gsd-path-research/references/dispatch.md +131 -0
- package/skills/gsd-path-research/references/researcher.md +42 -0
- package/skills/gsd-path-research/scripts/_common.py +180 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-research/scripts/isolation.py +3538 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-research/scripts/state_promote.py +743 -0
- package/skills/gsd-path-research/templates/evidence.md +24 -0
- package/skills/gsd-path-research/templates/research-handoff.md +25 -0
- package/skills/gsd-path-roadmap/SKILL.md +215 -0
- package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
- package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
- package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
- package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
- package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
- package/skills/gsd-path-ship/SKILL.md +412 -0
- package/skills/gsd-path-ship/agents/openai.yaml +6 -0
- package/skills/gsd-path-ship/references/dispatch.md +131 -0
- package/skills/gsd-path-ship/references/reviewer.md +188 -0
- package/skills/gsd-path-ship/scripts/_common.py +180 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-ship/scripts/build_state.py +854 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-ship/scripts/integration.py +1433 -0
- package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
- package/skills/gsd-path-ship/templates/final-review.md +27 -0
- package/skills/gsd-path-ship/templates/gap-review.md +20 -0
- package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
- package/skills/gsd-path-ship/templates/wave-review.md +64 -0
- package/skills/gsd-path-undo/SKILL.md +45 -0
- package/skills/gsd-path-undo/agents/openai.yaml +6 -0
- package/skills/gsd-path-undo/scripts/_common.py +180 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
- package/skills/path/BUILD.md +700 -0
- package/skills/path/DECIDE.md +115 -0
- package/skills/path/DEFINE.md +297 -0
- package/skills/path/DOCS-AUDIT.md +190 -0
- package/skills/path/INSPECT.md +167 -0
- package/skills/path/PLAN.md +411 -0
- package/skills/path/RESEARCH.md +159 -0
- package/skills/path/ROADMAP.md +215 -0
- package/skills/path/SHIP.md +412 -0
- package/skills/path/SKILL.md +471 -0
- package/skills/path/agents/openai.yaml +6 -0
- package/skills/path/references/codebase-mapper.md +52 -0
- package/skills/path/references/coder.md +90 -0
- package/skills/path/references/decider.md +36 -0
- package/skills/path/references/dispatch.md +131 -0
- package/skills/path/references/docs-auditor.md +60 -0
- package/skills/path/references/planner.md +104 -0
- package/skills/path/references/researcher.md +42 -0
- package/skills/path/references/reviewer.md +188 -0
- package/skills/path/references/roadmapper.md +48 -0
- package/skills/path/scripts/_common.py +180 -0
- package/skills/path/scripts/archive_milestone.py +2265 -0
- package/skills/path/scripts/bootstrap_repository.py +710 -0
- package/skills/path/scripts/build_state.py +854 -0
- package/skills/path/scripts/check_docs_audit.py +463 -0
- package/skills/path/scripts/check_handoffs.py +1855 -0
- package/skills/path/scripts/check_task_briefs.py +376 -0
- package/skills/path/scripts/check_update.py +93 -0
- package/skills/path/scripts/detect_project.py +1517 -0
- package/skills/path/scripts/discussion_records.py +619 -0
- package/skills/path/scripts/discussion_validate.py +1321 -0
- package/skills/path/scripts/dispatch_driver.py +1812 -0
- package/skills/path/scripts/integration.py +1433 -0
- package/skills/path/scripts/isolation.py +3538 -0
- package/skills/path/scripts/lean_verification.py +256 -0
- package/skills/path/scripts/loop_run.py +837 -0
- package/skills/path/scripts/pipeline_git.py +940 -0
- package/skills/path/scripts/pipeline_state.py +2291 -0
- package/skills/path/scripts/promote_lookahead.py +415 -0
- package/skills/path/scripts/review_findings.py +714 -0
- package/skills/path/scripts/review_panel.py +553 -0
- package/skills/path/scripts/state_checkpoint.py +1039 -0
- package/skills/path/scripts/state_promote.py +743 -0
- package/skills/path/scripts/token_budget.py +146 -0
- package/skills/path/scripts/workflow_run.py +117 -0
- package/skills/path/templates/answers.md +29 -0
- package/skills/path/templates/archive-manifest.md +37 -0
- package/skills/path/templates/charter.md +50 -0
- package/skills/path/templates/codebase.md +46 -0
- package/skills/path/templates/dialogue.md +21 -0
- package/skills/path/templates/docs-audit.md +69 -0
- package/skills/path/templates/evidence.md +24 -0
- package/skills/path/templates/final-review.md +27 -0
- package/skills/path/templates/gap-review.md +20 -0
- package/skills/path/templates/intent.md +98 -0
- package/skills/path/templates/loop.md +59 -0
- package/skills/path/templates/patch-findings.md +16 -0
- package/skills/path/templates/plan-panel.md +25 -0
- package/skills/path/templates/plan.md +89 -0
- package/skills/path/templates/repository.md +16 -0
- package/skills/path/templates/research-handoff.md +25 -0
- package/skills/path/templates/roadmap.md +38 -0
- package/skills/path/templates/skeptic.md +36 -0
- package/skills/path/templates/state.md +27 -0
- package/skills/path/templates/synthesis.md +36 -0
- package/skills/path/templates/task.md +74 -0
- package/skills/path/templates/wave-panel.md +25 -0
- package/skills/path/templates/wave-review.md +64 -0
|
@@ -0,0 +1,1433 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Integrate a shipped milestone by merge, pull request and tag for archive_milestone."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from pipeline_git import (
|
|
12
|
+
bound_branch_name,
|
|
13
|
+
default_branch_name,
|
|
14
|
+
integrate_commit_body,
|
|
15
|
+
integrate_subject,
|
|
16
|
+
is_bound_branch,
|
|
17
|
+
milestone_id,
|
|
18
|
+
milestone_number,
|
|
19
|
+
ship_subject,
|
|
20
|
+
)
|
|
21
|
+
from pipeline_state import PipelineState
|
|
22
|
+
from isolation import publication_base
|
|
23
|
+
import _common
|
|
24
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
25
|
+
from scripts.pipeline_git import (
|
|
26
|
+
bound_branch_name,
|
|
27
|
+
default_branch_name,
|
|
28
|
+
integrate_commit_body,
|
|
29
|
+
integrate_subject,
|
|
30
|
+
is_bound_branch,
|
|
31
|
+
milestone_id,
|
|
32
|
+
milestone_number,
|
|
33
|
+
ship_subject,
|
|
34
|
+
)
|
|
35
|
+
from scripts.pipeline_state import PipelineState
|
|
36
|
+
from scripts.isolation import publication_base
|
|
37
|
+
from scripts import _common
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
run_git = _common.run_git
|
|
41
|
+
run_command = _common.run_command
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_remote_default(project: Path) -> str:
|
|
45
|
+
result = run_git(project, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
|
|
46
|
+
remote_ref = result.stdout.strip()
|
|
47
|
+
prefix = "refs/remotes/"
|
|
48
|
+
if result.returncode != 0 or not remote_ref.startswith(prefix):
|
|
49
|
+
raise ArchiveError("origin/HEAD is unresolved; fetch before validating integration")
|
|
50
|
+
return remote_ref.removeprefix(prefix)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def live_remote_ref(project: Path, ref: str) -> Optional[str]:
|
|
54
|
+
result = run_git(
|
|
55
|
+
project,
|
|
56
|
+
"ls-remote",
|
|
57
|
+
"--exit-code",
|
|
58
|
+
"--refs",
|
|
59
|
+
"origin",
|
|
60
|
+
ref,
|
|
61
|
+
)
|
|
62
|
+
if result.returncode == 2:
|
|
63
|
+
return None
|
|
64
|
+
if result.returncode != 0:
|
|
65
|
+
raise ArchiveError(f"could not inspect {ref} on origin: git ls-remote failed")
|
|
66
|
+
rows = [line.split("\t", 1) for line in result.stdout.splitlines() if line]
|
|
67
|
+
if len(rows) != 1 or len(rows[0]) != 2 or rows[0][1] != ref:
|
|
68
|
+
raise ArchiveError(f"origin returned ambiguous ref data for {ref}")
|
|
69
|
+
return rows[0][0]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def publishable_bound_branch(project: Path, branch: str, ship_commit: str) -> Optional[str]:
|
|
73
|
+
"""The live origin SHA the bound ref may move from: absent, the ship
|
|
74
|
+
commit, or an owner-adopted rebased head proven by its archived receipt."""
|
|
75
|
+
remote_sha = live_remote_ref(project, f"refs/heads/{branch}")
|
|
76
|
+
if remote_sha is None or remote_sha == ship_commit:
|
|
77
|
+
return remote_sha
|
|
78
|
+
try:
|
|
79
|
+
approved_base = publication_base(project, branch, ship_commit)
|
|
80
|
+
except (RuntimeError, OSError) as error:
|
|
81
|
+
raise ArchiveError(f"rebase publication proof failed: {error}") from error
|
|
82
|
+
if remote_sha != approved_base:
|
|
83
|
+
raise ArchiveError(f"origin/{branch} moved or collides: {remote_sha} != {ship_commit}")
|
|
84
|
+
return remote_sha
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def publish_bound_branch(project: Path, branch: str, ship_commit: str) -> None:
|
|
88
|
+
remote_ref = f"refs/heads/{branch}"
|
|
89
|
+
remote_sha = publishable_bound_branch(project, branch, ship_commit)
|
|
90
|
+
if remote_sha != ship_commit:
|
|
91
|
+
# An absent ref carries an empty lease; an adopted head leases its exact SHA.
|
|
92
|
+
archive_milestone.require_git_success(
|
|
93
|
+
run_git(
|
|
94
|
+
project,
|
|
95
|
+
"push",
|
|
96
|
+
f"--force-with-lease={remote_ref}:{remote_sha or ''}",
|
|
97
|
+
"origin",
|
|
98
|
+
f"{ship_commit}:{remote_ref}",
|
|
99
|
+
),
|
|
100
|
+
"publish bound branch under its observed lease",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
confirmed_sha = live_remote_ref(project, remote_ref)
|
|
104
|
+
if confirmed_sha != ship_commit:
|
|
105
|
+
raise ArchiveError(f"origin/{branch} does not equal the ship commit after publish")
|
|
106
|
+
archive_milestone.require_git_success(
|
|
107
|
+
run_git(
|
|
108
|
+
project,
|
|
109
|
+
"update-ref",
|
|
110
|
+
f"refs/remotes/origin/{branch}",
|
|
111
|
+
ship_commit,
|
|
112
|
+
),
|
|
113
|
+
"refresh local bound-branch ref",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def refresh_origin(repo: Path) -> dict:
|
|
118
|
+
"""Fetch origin, refresh origin/HEAD, and mirror milestone tags."""
|
|
119
|
+
project = repo.resolve()
|
|
120
|
+
archive_milestone.require_git_success(run_git(project, "fetch", "--prune", "origin"), "fetch origin")
|
|
121
|
+
archive_milestone.require_git_success(
|
|
122
|
+
run_git(project, "remote", "set-head", "origin", "--auto"),
|
|
123
|
+
"refresh origin/HEAD",
|
|
124
|
+
)
|
|
125
|
+
mirrored = run_git(
|
|
126
|
+
project,
|
|
127
|
+
"fetch",
|
|
128
|
+
"--prune",
|
|
129
|
+
"origin",
|
|
130
|
+
"refs/tags/milestone/*:refs/remotes/origin/tags/milestone/*",
|
|
131
|
+
)
|
|
132
|
+
if mirrored.returncode != 0:
|
|
133
|
+
detail = (mirrored.stderr or mirrored.stdout).strip()
|
|
134
|
+
if "couldn't find remote ref" not in detail.casefold() and detail:
|
|
135
|
+
raise ArchiveError(f"could not refresh published milestone tags: {detail}")
|
|
136
|
+
remote_default = resolve_remote_default(project)
|
|
137
|
+
return {"remote_default": remote_default}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def optional_ref(project: Path, ref: str) -> Optional[str]:
|
|
141
|
+
result = run_git(project, "rev-parse", "--verify", "--quiet", ref)
|
|
142
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def integration_names(project: Path, archive_name: str) -> tuple[str, Path]:
|
|
146
|
+
identifier = milestone_id(milestone_number(archive_name))
|
|
147
|
+
branch = f"gsd-path-integrate/{identifier}"
|
|
148
|
+
worktree = project.parent / f".{project.name}-gsd-path-integrate-{identifier}"
|
|
149
|
+
return branch, worktree
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def registered_worktree(project: Path, branch: str) -> Optional[Path]:
|
|
153
|
+
output = archive_milestone.require_git_success(
|
|
154
|
+
run_git(project, "worktree", "list", "--porcelain"),
|
|
155
|
+
"inspect integration worktrees",
|
|
156
|
+
)
|
|
157
|
+
branch_ref = f"branch refs/heads/{branch}"
|
|
158
|
+
matches = []
|
|
159
|
+
for record in output.split("\n\n"):
|
|
160
|
+
lines = record.splitlines()
|
|
161
|
+
if branch_ref not in lines:
|
|
162
|
+
continue
|
|
163
|
+
worktree_lines = [
|
|
164
|
+
line.removeprefix("worktree ")
|
|
165
|
+
for line in lines
|
|
166
|
+
if line.startswith("worktree ")
|
|
167
|
+
]
|
|
168
|
+
if len(worktree_lines) != 1:
|
|
169
|
+
raise ArchiveError(
|
|
170
|
+
f"integration branch {branch} has a malformed worktree record"
|
|
171
|
+
)
|
|
172
|
+
matches.append(Path(worktree_lines[0]).resolve())
|
|
173
|
+
if len(matches) > 1:
|
|
174
|
+
raise ArchiveError(f"integration branch {branch} is checked out more than once")
|
|
175
|
+
return matches[0] if matches else None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def remove_registered_worktree(project: Path, branch: str, expected_path: Path) -> None:
|
|
179
|
+
registered = registered_worktree(project, branch)
|
|
180
|
+
if registered is None:
|
|
181
|
+
if expected_path.exists() or expected_path.is_symlink():
|
|
182
|
+
raise ArchiveError(f"unregistered integration worktree path exists: {expected_path}")
|
|
183
|
+
return
|
|
184
|
+
if registered != expected_path.resolve():
|
|
185
|
+
raise ArchiveError(
|
|
186
|
+
f"integration branch {branch} is checked out at unexpected path: {registered}"
|
|
187
|
+
)
|
|
188
|
+
status = archive_milestone.require_git_success(
|
|
189
|
+
run_git(registered, "status", "--porcelain", "--untracked-files=all"),
|
|
190
|
+
"inspect integration worktree status",
|
|
191
|
+
)
|
|
192
|
+
if status:
|
|
193
|
+
raise ArchiveError("integration worktree is dirty; refusing cleanup")
|
|
194
|
+
archive_milestone.require_git_success(
|
|
195
|
+
run_git(project, "worktree", "remove", str(registered)),
|
|
196
|
+
"remove integration worktree",
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def delete_integration_branch(project: Path, branch: str, expected: str) -> None:
|
|
201
|
+
ref = f"refs/heads/{branch}"
|
|
202
|
+
actual = optional_ref(project, ref)
|
|
203
|
+
if actual is None:
|
|
204
|
+
return
|
|
205
|
+
if actual != expected:
|
|
206
|
+
raise ArchiveError(f"integration branch {branch} moved unexpectedly")
|
|
207
|
+
archive_milestone.require_git_success(
|
|
208
|
+
run_git(project, "update-ref", "-d", ref, expected),
|
|
209
|
+
"delete integration branch",
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def require_generated_integration_commit(
|
|
214
|
+
project: Path,
|
|
215
|
+
commit: str,
|
|
216
|
+
archive_path: str,
|
|
217
|
+
archive_name: str,
|
|
218
|
+
ship_commit: str,
|
|
219
|
+
default_name: str,
|
|
220
|
+
bound_branch: str,
|
|
221
|
+
first_parent: Optional[str] = None,
|
|
222
|
+
) -> None:
|
|
223
|
+
subject = archive_milestone.require_git_success(
|
|
224
|
+
run_git(project, "show", "-s", "--format=%s", commit),
|
|
225
|
+
"inspect integration commit subject",
|
|
226
|
+
)
|
|
227
|
+
expected_subject = integrate_subject(archive_name, default_name)
|
|
228
|
+
if subject != expected_subject:
|
|
229
|
+
raise ArchiveError(f"integration commit subject must be {expected_subject!r}")
|
|
230
|
+
archive_milestone.require_canonical_commit_body(
|
|
231
|
+
project,
|
|
232
|
+
commit,
|
|
233
|
+
expected_subject,
|
|
234
|
+
integrate_commit_body(archive_path, ship_commit, default_name, bound_branch),
|
|
235
|
+
"integration",
|
|
236
|
+
)
|
|
237
|
+
parents = archive_milestone.require_git_success(
|
|
238
|
+
run_git(project, "rev-list", "--parents", "-n", "1", commit),
|
|
239
|
+
"inspect integration merge parents",
|
|
240
|
+
).split()
|
|
241
|
+
if len(parents) != 3 or parents[2] != ship_commit:
|
|
242
|
+
raise ArchiveError("integration commit is not the canonical merge of the ship commit")
|
|
243
|
+
if first_parent is not None and parents[1] != first_parent:
|
|
244
|
+
raise ArchiveError("integration merge was created from a stale remote default")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def discard_unpublished_stale_integration(
|
|
248
|
+
project: Path,
|
|
249
|
+
branch: str,
|
|
250
|
+
merge_commit: str,
|
|
251
|
+
remote_default_sha: str,
|
|
252
|
+
default_name: str,
|
|
253
|
+
archive_name: str,
|
|
254
|
+
bound_branch: str,
|
|
255
|
+
) -> None:
|
|
256
|
+
live_default = live_remote_ref(project, f"refs/heads/{default_name}")
|
|
257
|
+
if live_default != remote_default_sha:
|
|
258
|
+
raise ArchiveError(f"origin/{default_name} advanced again; rerun integrate")
|
|
259
|
+
published = run_git(
|
|
260
|
+
project, "merge-base", "--is-ancestor", merge_commit, remote_default_sha
|
|
261
|
+
)
|
|
262
|
+
if published.returncode == 0:
|
|
263
|
+
raise ArchiveError("stale integration merge is already published on origin/main")
|
|
264
|
+
if published.returncode != 1:
|
|
265
|
+
archive_milestone.require_git_success(published, "inspect stale integration publication")
|
|
266
|
+
|
|
267
|
+
bound_ref = f"refs/heads/{bound_branch}"
|
|
268
|
+
if live_remote_ref(project, bound_ref) is not None:
|
|
269
|
+
raise ArchiveError(
|
|
270
|
+
"stale integration recovery requires the remote bound branch to be absent"
|
|
271
|
+
)
|
|
272
|
+
tag_name = f"milestone/{archive_name}"
|
|
273
|
+
published_tag_ref = f"refs/tags/{tag_name}"
|
|
274
|
+
if live_remote_ref(project, published_tag_ref) is not None:
|
|
275
|
+
raise ArchiveError(
|
|
276
|
+
"stale integration recovery requires the remote milestone tag to be absent"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
local_tag_ref = f"refs/tags/{tag_name}"
|
|
280
|
+
local_tag_object = optional_ref(project, local_tag_ref)
|
|
281
|
+
if local_tag_object is not None:
|
|
282
|
+
require_annotated_tag(
|
|
283
|
+
project,
|
|
284
|
+
local_tag_ref,
|
|
285
|
+
merge_commit,
|
|
286
|
+
f"stale milestone tag {tag_name}",
|
|
287
|
+
)
|
|
288
|
+
archive_milestone.require_git_success(
|
|
289
|
+
run_git(project, "update-ref", "-d", local_tag_ref, local_tag_object),
|
|
290
|
+
"delete unpublished stale milestone tag",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
tracking_tag_ref = f"refs/remotes/origin/tags/{tag_name}"
|
|
294
|
+
tracking_tag_object = optional_ref(project, tracking_tag_ref)
|
|
295
|
+
if tracking_tag_object is not None:
|
|
296
|
+
if local_tag_object is None or tracking_tag_object != local_tag_object:
|
|
297
|
+
raise ArchiveError("local published-tag tracking ref is not safely recoverable")
|
|
298
|
+
archive_milestone.require_git_success(
|
|
299
|
+
run_git(
|
|
300
|
+
project,
|
|
301
|
+
"update-ref",
|
|
302
|
+
"-d",
|
|
303
|
+
tracking_tag_ref,
|
|
304
|
+
tracking_tag_object,
|
|
305
|
+
),
|
|
306
|
+
"delete stale published-tag tracking ref",
|
|
307
|
+
)
|
|
308
|
+
delete_integration_branch(project, branch, merge_commit)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def create_integration_merge(
|
|
312
|
+
project: Path,
|
|
313
|
+
worktree: Path,
|
|
314
|
+
branch: str,
|
|
315
|
+
remote_default_sha: str,
|
|
316
|
+
archive_path: str,
|
|
317
|
+
archive_name: str,
|
|
318
|
+
ship_commit: str,
|
|
319
|
+
default_name: str,
|
|
320
|
+
bound_branch: str,
|
|
321
|
+
) -> tuple[str, bool]:
|
|
322
|
+
if worktree.exists() or worktree.is_symlink():
|
|
323
|
+
raise ArchiveError(f"integration worktree path already exists: {worktree}")
|
|
324
|
+
branch_ref = f"refs/heads/{branch}"
|
|
325
|
+
branch_tip = optional_ref(project, branch_ref)
|
|
326
|
+
if branch_tip is None:
|
|
327
|
+
add = run_git(
|
|
328
|
+
project,
|
|
329
|
+
"worktree",
|
|
330
|
+
"add",
|
|
331
|
+
"--quiet",
|
|
332
|
+
"-b",
|
|
333
|
+
branch,
|
|
334
|
+
str(worktree),
|
|
335
|
+
remote_default_sha,
|
|
336
|
+
)
|
|
337
|
+
elif branch_tip == remote_default_sha:
|
|
338
|
+
add = run_git(project, "worktree", "add", "--quiet", str(worktree), branch)
|
|
339
|
+
else:
|
|
340
|
+
require_generated_integration_commit(
|
|
341
|
+
project,
|
|
342
|
+
branch_tip,
|
|
343
|
+
archive_path,
|
|
344
|
+
archive_name,
|
|
345
|
+
ship_commit,
|
|
346
|
+
default_name,
|
|
347
|
+
bound_branch,
|
|
348
|
+
)
|
|
349
|
+
parents = archive_milestone.require_git_success(
|
|
350
|
+
run_git(project, "rev-list", "--parents", "-n", "1", branch_tip),
|
|
351
|
+
"inspect existing integration merge parents",
|
|
352
|
+
).split()
|
|
353
|
+
if parents[1] == remote_default_sha:
|
|
354
|
+
return branch_tip, False
|
|
355
|
+
discard_unpublished_stale_integration(
|
|
356
|
+
project,
|
|
357
|
+
branch,
|
|
358
|
+
branch_tip,
|
|
359
|
+
remote_default_sha,
|
|
360
|
+
default_name,
|
|
361
|
+
archive_name,
|
|
362
|
+
bound_branch,
|
|
363
|
+
)
|
|
364
|
+
add = run_git(
|
|
365
|
+
project,
|
|
366
|
+
"worktree",
|
|
367
|
+
"add",
|
|
368
|
+
"--quiet",
|
|
369
|
+
"-b",
|
|
370
|
+
branch,
|
|
371
|
+
str(worktree),
|
|
372
|
+
remote_default_sha,
|
|
373
|
+
)
|
|
374
|
+
archive_milestone.require_git_success(add, "create integration worktree")
|
|
375
|
+
|
|
376
|
+
merge = run_git(
|
|
377
|
+
worktree,
|
|
378
|
+
"merge",
|
|
379
|
+
"--no-ff",
|
|
380
|
+
"-m",
|
|
381
|
+
integrate_subject(archive_name, default_name),
|
|
382
|
+
"-m",
|
|
383
|
+
integrate_commit_body(archive_path, ship_commit, default_name, bound_branch),
|
|
384
|
+
ship_commit,
|
|
385
|
+
)
|
|
386
|
+
if merge.returncode != 0:
|
|
387
|
+
conflicted = optional_ref(worktree, "MERGE_HEAD") is not None
|
|
388
|
+
detail = (merge.stderr or merge.stdout).strip()
|
|
389
|
+
conflicts = ""
|
|
390
|
+
if conflicted:
|
|
391
|
+
conflicts = run_git(
|
|
392
|
+
worktree, "diff", "--name-only", "--diff-filter=U"
|
|
393
|
+
).stdout.strip()
|
|
394
|
+
archive_milestone.require_git_success(
|
|
395
|
+
run_git(worktree, "merge", "--abort"),
|
|
396
|
+
"abort integration merge",
|
|
397
|
+
)
|
|
398
|
+
remove_registered_worktree(project, branch, worktree)
|
|
399
|
+
delete_integration_branch(project, branch, remote_default_sha)
|
|
400
|
+
if conflicted:
|
|
401
|
+
raise ArchiveError(
|
|
402
|
+
"integration merge conflicted; Git aborted it without resolving files: "
|
|
403
|
+
+ ", ".join(conflicts.splitlines())
|
|
404
|
+
+ "; closed milestone remains unchanged; run gsd-path-forensics and "
|
|
405
|
+
"obtain a user-approved integration recovery plan; do not merge into or re-ship the bound branch"
|
|
406
|
+
)
|
|
407
|
+
raise ArchiveError(f"create integration merge failed: {detail}")
|
|
408
|
+
|
|
409
|
+
merge_commit = archive_milestone.require_git_success(
|
|
410
|
+
run_git(worktree, "rev-parse", "HEAD"), "resolve integration merge"
|
|
411
|
+
)
|
|
412
|
+
require_generated_integration_commit(
|
|
413
|
+
project,
|
|
414
|
+
merge_commit,
|
|
415
|
+
archive_path,
|
|
416
|
+
archive_name,
|
|
417
|
+
ship_commit,
|
|
418
|
+
default_name,
|
|
419
|
+
bound_branch,
|
|
420
|
+
remote_default_sha,
|
|
421
|
+
)
|
|
422
|
+
return merge_commit, True
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def require_annotated_tag(
|
|
426
|
+
project: Path,
|
|
427
|
+
ref: str,
|
|
428
|
+
merge_commit: str,
|
|
429
|
+
label: str,
|
|
430
|
+
message: Optional[str] = None,
|
|
431
|
+
) -> str:
|
|
432
|
+
object_id = optional_ref(project, ref)
|
|
433
|
+
if object_id is None:
|
|
434
|
+
raise ArchiveError(f"missing {label}")
|
|
435
|
+
tag_type = archive_milestone.require_git_success(
|
|
436
|
+
run_git(project, "cat-file", "-t", ref), f"inspect {label}"
|
|
437
|
+
)
|
|
438
|
+
if tag_type != "tag":
|
|
439
|
+
raise ArchiveError(f"{label} must be annotated")
|
|
440
|
+
target = archive_milestone.require_git_success(
|
|
441
|
+
run_git(project, "rev-parse", f"{ref}^{{commit}}"), f"resolve {label} target"
|
|
442
|
+
)
|
|
443
|
+
if target != merge_commit:
|
|
444
|
+
raise ArchiveError(f"{label} does not point at the integration merge")
|
|
445
|
+
if message is not None:
|
|
446
|
+
actual_message = archive_milestone.require_git_success(
|
|
447
|
+
run_git(project, "for-each-ref", "--format=%(contents)", ref),
|
|
448
|
+
f"inspect {label} message",
|
|
449
|
+
)
|
|
450
|
+
if actual_message != message.strip():
|
|
451
|
+
raise ArchiveError(f"{label} has unexpected metadata")
|
|
452
|
+
return object_id
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def ensure_integration_tag(
|
|
456
|
+
project: Path,
|
|
457
|
+
tag_name: str,
|
|
458
|
+
merge_commit: str,
|
|
459
|
+
message: Optional[str] = None,
|
|
460
|
+
) -> tuple[str, bool]:
|
|
461
|
+
tag_ref = f"refs/tags/{tag_name}"
|
|
462
|
+
tracking_ref = f"refs/remotes/origin/tags/{tag_name}"
|
|
463
|
+
local_object = optional_ref(project, tag_ref)
|
|
464
|
+
published_object = live_remote_ref(project, tag_ref)
|
|
465
|
+
tracking_object = optional_ref(project, tracking_ref)
|
|
466
|
+
if tracking_object != published_object:
|
|
467
|
+
if published_object is None:
|
|
468
|
+
archive_milestone.require_git_success(
|
|
469
|
+
run_git(project, "update-ref", "-d", tracking_ref, tracking_object),
|
|
470
|
+
"prune local milestone-tag ref",
|
|
471
|
+
)
|
|
472
|
+
else:
|
|
473
|
+
archive_milestone.require_git_success(
|
|
474
|
+
run_git(project, "update-ref", tracking_ref, published_object),
|
|
475
|
+
"refresh local milestone-tag ref",
|
|
476
|
+
)
|
|
477
|
+
if published_object is not None:
|
|
478
|
+
published_object = require_annotated_tag(
|
|
479
|
+
project,
|
|
480
|
+
tracking_ref,
|
|
481
|
+
merge_commit,
|
|
482
|
+
f"published milestone tag {tag_name}",
|
|
483
|
+
message,
|
|
484
|
+
)
|
|
485
|
+
if local_object is None:
|
|
486
|
+
if published_object is not None:
|
|
487
|
+
archive_milestone.require_git_success(
|
|
488
|
+
run_git(project, "update-ref", tag_ref, published_object),
|
|
489
|
+
"restore local milestone tag",
|
|
490
|
+
)
|
|
491
|
+
else:
|
|
492
|
+
tag_message = message or f"milestone {tag_name.removeprefix('milestone/')}"
|
|
493
|
+
archive_milestone.require_git_success(
|
|
494
|
+
run_git(
|
|
495
|
+
project,
|
|
496
|
+
"tag",
|
|
497
|
+
"--no-sign",
|
|
498
|
+
"-a",
|
|
499
|
+
"-m",
|
|
500
|
+
tag_message,
|
|
501
|
+
tag_name,
|
|
502
|
+
merge_commit,
|
|
503
|
+
),
|
|
504
|
+
"create milestone tag",
|
|
505
|
+
)
|
|
506
|
+
local_object = require_annotated_tag(
|
|
507
|
+
project,
|
|
508
|
+
tag_ref,
|
|
509
|
+
merge_commit,
|
|
510
|
+
f"milestone tag {tag_name}",
|
|
511
|
+
message,
|
|
512
|
+
)
|
|
513
|
+
if published_object is not None and published_object != local_object:
|
|
514
|
+
raise ArchiveError(f"local and published milestone tag {tag_name} differ")
|
|
515
|
+
return local_object, published_object is not None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def github_repository(project: Path) -> str:
|
|
519
|
+
remote = archive_milestone.require_git_success(
|
|
520
|
+
run_git(project, "remote", "get-url", "origin"),
|
|
521
|
+
"resolve origin URL",
|
|
522
|
+
)
|
|
523
|
+
patterns = (
|
|
524
|
+
r"https://github\.com/([^/]+/[^/]+?)(?:\.git)?$",
|
|
525
|
+
r"git@github\.com:([^/]+/[^/]+?)(?:\.git)?$",
|
|
526
|
+
r"ssh://git@github\.com/([^/]+/[^/]+?)(?:\.git)?$",
|
|
527
|
+
)
|
|
528
|
+
for pattern in patterns:
|
|
529
|
+
match = re.fullmatch(pattern, remote)
|
|
530
|
+
if match:
|
|
531
|
+
return match.group(1)
|
|
532
|
+
raise ArchiveError("pull-request integration requires a GitHub.com origin")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def require_github_authentication() -> None:
|
|
536
|
+
archive_milestone.require_git_success(
|
|
537
|
+
run_command("gh", "auth", "status", "--hostname", GITHUB_HOST),
|
|
538
|
+
"verify GitHub authentication",
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def github_api_json(*arguments: str) -> object:
|
|
543
|
+
output = archive_milestone.require_git_success(
|
|
544
|
+
run_command("gh", "api", "--hostname", GITHUB_HOST, *arguments),
|
|
545
|
+
"call GitHub API",
|
|
546
|
+
)
|
|
547
|
+
try:
|
|
548
|
+
return json.loads(output)
|
|
549
|
+
except json.JSONDecodeError as error:
|
|
550
|
+
raise ArchiveError("GitHub API returned invalid JSON") from error
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def require_pull_request_shape(value: object) -> dict:
|
|
554
|
+
if not isinstance(value, dict):
|
|
555
|
+
raise ArchiveError("GitHub pull request response is not an object")
|
|
556
|
+
required = {
|
|
557
|
+
"number",
|
|
558
|
+
"state",
|
|
559
|
+
"html_url",
|
|
560
|
+
"merged_at",
|
|
561
|
+
"merge_commit_sha",
|
|
562
|
+
"base",
|
|
563
|
+
"head",
|
|
564
|
+
}
|
|
565
|
+
if not required.issubset(value):
|
|
566
|
+
raise ArchiveError("GitHub pull request response is missing fields")
|
|
567
|
+
if not isinstance(value["base"], dict) or not isinstance(value["head"], dict):
|
|
568
|
+
raise ArchiveError("GitHub pull request refs are invalid")
|
|
569
|
+
number = value["number"]
|
|
570
|
+
if not isinstance(number, int) or isinstance(number, bool) or number < 1:
|
|
571
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
572
|
+
if value["state"] not in {"open", "closed"}:
|
|
573
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
574
|
+
html_url = value["html_url"]
|
|
575
|
+
if not isinstance(html_url, str) or re.fullmatch(
|
|
576
|
+
rf"https://github\.com/[^/]+/[^/]+/pull/{number}", html_url
|
|
577
|
+
) is None:
|
|
578
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
579
|
+
merged_at = value["merged_at"]
|
|
580
|
+
if merged_at is not None and not isinstance(merged_at, str):
|
|
581
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
582
|
+
merge_commit = value["merge_commit_sha"]
|
|
583
|
+
if merge_commit is not None and (
|
|
584
|
+
not isinstance(merge_commit, str)
|
|
585
|
+
or re.fullmatch(r"[0-9a-f]{40}", merge_commit) is None
|
|
586
|
+
):
|
|
587
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
588
|
+
if not isinstance(value["base"].get("ref"), str):
|
|
589
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
590
|
+
if not isinstance(value["head"].get("ref"), str):
|
|
591
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
592
|
+
head_sha = value["head"].get("sha")
|
|
593
|
+
if not isinstance(head_sha, str) or re.fullmatch(r"[0-9a-f]{40}", head_sha) is None:
|
|
594
|
+
raise ArchiveError("GitHub pull request response has invalid fields")
|
|
595
|
+
return value
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def require_pull_request_pages(value: object) -> list[dict]:
|
|
599
|
+
if not isinstance(value, list) or any(
|
|
600
|
+
not isinstance(page, list) for page in value
|
|
601
|
+
):
|
|
602
|
+
raise ArchiveError("GitHub pull request pages are invalid")
|
|
603
|
+
return [require_pull_request_shape(item) for page in value for item in page]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def pull_request_head_matches_repository(pull: dict, repository: str) -> bool:
|
|
607
|
+
head_repository = pull["head"].get("repo")
|
|
608
|
+
full_name = (
|
|
609
|
+
head_repository.get("full_name")
|
|
610
|
+
if isinstance(head_repository, dict)
|
|
611
|
+
else None
|
|
612
|
+
)
|
|
613
|
+
return isinstance(full_name, str) and full_name.casefold() == repository.casefold()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def require_pull_request_identity(
|
|
617
|
+
repository: str,
|
|
618
|
+
pull: dict,
|
|
619
|
+
branch: str,
|
|
620
|
+
ship_commit: str,
|
|
621
|
+
) -> None:
|
|
622
|
+
if pull["base"].get("ref") != "main":
|
|
623
|
+
raise ArchiveError("GitHub pull request base is not main")
|
|
624
|
+
if pull["head"].get("ref") != branch:
|
|
625
|
+
raise ArchiveError("GitHub pull request head is not the bound branch")
|
|
626
|
+
if pull["head"].get("sha") != ship_commit:
|
|
627
|
+
raise ArchiveError("GitHub pull request head is not the ship commit")
|
|
628
|
+
if not pull_request_head_matches_repository(pull, repository):
|
|
629
|
+
raise ArchiveError("GitHub pull request head repository is not origin")
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def find_pull_request(repository: str, branch: str, ship_commit: str) -> Optional[dict]:
|
|
633
|
+
pulls = require_pull_request_pages(
|
|
634
|
+
github_api_json(
|
|
635
|
+
f"repos/{repository}/pulls",
|
|
636
|
+
"--method",
|
|
637
|
+
"GET",
|
|
638
|
+
"-f",
|
|
639
|
+
"state=all",
|
|
640
|
+
"-f",
|
|
641
|
+
"base=main",
|
|
642
|
+
"--paginate",
|
|
643
|
+
"--slurp",
|
|
644
|
+
)
|
|
645
|
+
)
|
|
646
|
+
pulls = [
|
|
647
|
+
pull
|
|
648
|
+
for pull in pulls
|
|
649
|
+
if pull["base"].get("ref") == "main"
|
|
650
|
+
and (
|
|
651
|
+
pull["head"].get("sha") == ship_commit
|
|
652
|
+
or (
|
|
653
|
+
pull["head"].get("ref") == branch
|
|
654
|
+
and pull_request_head_matches_repository(pull, repository)
|
|
655
|
+
)
|
|
656
|
+
)
|
|
657
|
+
]
|
|
658
|
+
if len(pulls) > 1:
|
|
659
|
+
raise ArchiveError("multiple pull requests target main from the ship commit")
|
|
660
|
+
if not pulls:
|
|
661
|
+
return None
|
|
662
|
+
pull = pulls[0]
|
|
663
|
+
require_pull_request_identity(repository, pull, branch, ship_commit)
|
|
664
|
+
return pull
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def pull_request_body(archive_path: str, ship_commit: str, branch: str) -> str:
|
|
668
|
+
return (
|
|
669
|
+
f"{integrate_commit_body(archive_path, ship_commit, 'main', branch)}"
|
|
670
|
+
f"\n\n---\n{PR_CREDIT_LINE}"
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def update_pull_request_body(
|
|
675
|
+
repository: str,
|
|
676
|
+
pull: dict,
|
|
677
|
+
archive_path: str,
|
|
678
|
+
ship_commit: str,
|
|
679
|
+
branch: str,
|
|
680
|
+
) -> dict:
|
|
681
|
+
expected = pull_request_body(archive_path, ship_commit, branch)
|
|
682
|
+
current = pull.get("body")
|
|
683
|
+
if isinstance(current, str) and current.rstrip().endswith(PR_CREDIT_LINE):
|
|
684
|
+
return pull
|
|
685
|
+
response = github_api_json(
|
|
686
|
+
f"repos/{repository}/pulls/{pull['number']}",
|
|
687
|
+
"--method",
|
|
688
|
+
"PATCH",
|
|
689
|
+
"-f",
|
|
690
|
+
f"body={expected}",
|
|
691
|
+
)
|
|
692
|
+
updated = require_pull_request_shape(response)
|
|
693
|
+
require_pull_request_identity(repository, updated, branch, ship_commit)
|
|
694
|
+
if updated.get("body") != expected:
|
|
695
|
+
raise ArchiveError("updated GitHub pull request is missing the credit footer")
|
|
696
|
+
return updated
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def create_pull_request(
|
|
700
|
+
repository: str,
|
|
701
|
+
branch: str,
|
|
702
|
+
archive_path: str,
|
|
703
|
+
archive_name: str,
|
|
704
|
+
ship_commit: str,
|
|
705
|
+
) -> dict:
|
|
706
|
+
body = pull_request_body(archive_path, ship_commit, branch)
|
|
707
|
+
response = github_api_json(
|
|
708
|
+
f"repos/{repository}/pulls",
|
|
709
|
+
"--method",
|
|
710
|
+
"POST",
|
|
711
|
+
"-f",
|
|
712
|
+
f"title={integrate_subject(archive_name, 'main')}",
|
|
713
|
+
"-f",
|
|
714
|
+
f"head={branch}",
|
|
715
|
+
"-f",
|
|
716
|
+
"base=main",
|
|
717
|
+
"-f",
|
|
718
|
+
f"body={body}",
|
|
719
|
+
)
|
|
720
|
+
pull = require_pull_request_shape(response)
|
|
721
|
+
require_pull_request_identity(repository, pull, branch, ship_commit)
|
|
722
|
+
return pull
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def pull_request_tag_message(
|
|
726
|
+
archive_name: str,
|
|
727
|
+
pull_request: str,
|
|
728
|
+
ship_commit: str,
|
|
729
|
+
landing: str,
|
|
730
|
+
) -> str:
|
|
731
|
+
return (
|
|
732
|
+
f"milestone {archive_name}\n\n"
|
|
733
|
+
"Mode: pull-request\n"
|
|
734
|
+
f"Pull-Request: {pull_request}\n"
|
|
735
|
+
f"Ship: {ship_commit}\n"
|
|
736
|
+
f"Landing: {landing}"
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def pull_request_tag_metadata(project: Path, tag_ref: str) -> dict[str, str]:
|
|
741
|
+
message = archive_milestone.require_git_success(
|
|
742
|
+
run_git(project, "for-each-ref", "--format=%(contents)", tag_ref),
|
|
743
|
+
"inspect pull-request milestone tag",
|
|
744
|
+
)
|
|
745
|
+
fields: dict[str, str] = {}
|
|
746
|
+
for line in message.splitlines():
|
|
747
|
+
match = re.fullmatch(r"(Mode|Pull-Request|Ship|Landing): (.+)", line)
|
|
748
|
+
if match:
|
|
749
|
+
if match.group(1) in fields:
|
|
750
|
+
raise ArchiveError("pull-request milestone tag repeats metadata")
|
|
751
|
+
fields[match.group(1)] = match.group(2)
|
|
752
|
+
if set(fields) != {"Mode", "Pull-Request", "Ship", "Landing"}:
|
|
753
|
+
raise ArchiveError("pull-request milestone tag is missing metadata")
|
|
754
|
+
if fields["Mode"] != "pull-request":
|
|
755
|
+
raise ArchiveError("pull-request milestone tag has the wrong mode")
|
|
756
|
+
return fields
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def require_pull_request_merge(
|
|
760
|
+
project: Path,
|
|
761
|
+
merge_commit: str,
|
|
762
|
+
ship_commit: str,
|
|
763
|
+
remote_default: str,
|
|
764
|
+
) -> None:
|
|
765
|
+
resolved = optional_ref(project, merge_commit)
|
|
766
|
+
if resolved != merge_commit:
|
|
767
|
+
raise ArchiveError("GitHub merge commit is unavailable after fetch")
|
|
768
|
+
parents = archive_milestone.require_git_success(
|
|
769
|
+
run_git(project, "rev-list", "--parents", "-n", "1", merge_commit),
|
|
770
|
+
"inspect pull-request merge parents",
|
|
771
|
+
).split()
|
|
772
|
+
if len(parents) != 3:
|
|
773
|
+
raise ArchiveError("pull-request integration must use a two-parent merge commit")
|
|
774
|
+
if parents[2] != ship_commit:
|
|
775
|
+
raise ArchiveError("pull-request merge second parent is not the ship commit")
|
|
776
|
+
first_parent = archive_milestone.require_git_success(
|
|
777
|
+
run_git(project, "rev-list", "--first-parent", remote_default),
|
|
778
|
+
"inspect remote-default first-parent history",
|
|
779
|
+
).splitlines()
|
|
780
|
+
if merge_commit not in first_parent:
|
|
781
|
+
raise ArchiveError("pull-request merge is not on origin/main first-parent history")
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def require_pull_request_merge_provenance(
|
|
785
|
+
repository: str,
|
|
786
|
+
number: int,
|
|
787
|
+
merge_commit: Optional[str] = None,
|
|
788
|
+
) -> None:
|
|
789
|
+
owner, name = repository.split("/", 1)
|
|
790
|
+
query = """query($owner:String!,$name:String!,$number:Int!){
|
|
791
|
+
repository(owner:$owner,name:$name){
|
|
792
|
+
pullRequest(number:$number){
|
|
793
|
+
mergeQueue:timelineItems(first:1,itemTypes:[ADDED_TO_MERGE_QUEUE_EVENT]){
|
|
794
|
+
nodes{__typename}
|
|
795
|
+
}
|
|
796
|
+
autoMerge:timelineItems(first:1,itemTypes:[AUTO_MERGE_ENABLED_EVENT]){
|
|
797
|
+
nodes{__typename}
|
|
798
|
+
}
|
|
799
|
+
mergeAction:timelineItems(last:1,itemTypes:[MERGED_EVENT]){
|
|
800
|
+
nodes{__typename ... on MergedEvent{actor{__typename login} commit{oid}}}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}"""
|
|
805
|
+
payload = github_api_json(
|
|
806
|
+
"graphql",
|
|
807
|
+
"-f",
|
|
808
|
+
f"query={query}",
|
|
809
|
+
"-F",
|
|
810
|
+
f"owner={owner}",
|
|
811
|
+
"-F",
|
|
812
|
+
f"name={name}",
|
|
813
|
+
"-F",
|
|
814
|
+
f"number={number}",
|
|
815
|
+
)
|
|
816
|
+
data = payload.get("data") if isinstance(payload, dict) else None
|
|
817
|
+
repository_data = data.get("repository") if isinstance(data, dict) else None
|
|
818
|
+
pull_request = (
|
|
819
|
+
repository_data.get("pullRequest")
|
|
820
|
+
if isinstance(repository_data, dict)
|
|
821
|
+
else None
|
|
822
|
+
)
|
|
823
|
+
checks = (
|
|
824
|
+
(
|
|
825
|
+
"mergeQueue",
|
|
826
|
+
"AddedToMergeQueueEvent",
|
|
827
|
+
"GitHub merge-queue provenance is invalid",
|
|
828
|
+
"pull-request integration cannot use a merge queue",
|
|
829
|
+
),
|
|
830
|
+
(
|
|
831
|
+
"autoMerge",
|
|
832
|
+
"AutoMergeEnabledEvent",
|
|
833
|
+
"GitHub auto-merge provenance is invalid",
|
|
834
|
+
"pull-request integration cannot use auto-merge",
|
|
835
|
+
),
|
|
836
|
+
)
|
|
837
|
+
for field, typename, invalid_message, rejection_message in checks:
|
|
838
|
+
timeline = pull_request.get(field) if isinstance(pull_request, dict) else None
|
|
839
|
+
nodes = timeline.get("nodes") if isinstance(timeline, dict) else None
|
|
840
|
+
if not isinstance(nodes, list):
|
|
841
|
+
raise ArchiveError(invalid_message)
|
|
842
|
+
if nodes:
|
|
843
|
+
if any(
|
|
844
|
+
not isinstance(node, dict) or node.get("__typename") != typename
|
|
845
|
+
for node in nodes
|
|
846
|
+
):
|
|
847
|
+
raise ArchiveError(invalid_message)
|
|
848
|
+
raise ArchiveError(rejection_message)
|
|
849
|
+
merge_timeline = (
|
|
850
|
+
pull_request.get("mergeAction")
|
|
851
|
+
if isinstance(pull_request, dict)
|
|
852
|
+
else None
|
|
853
|
+
)
|
|
854
|
+
merge_nodes = (
|
|
855
|
+
merge_timeline.get("nodes")
|
|
856
|
+
if isinstance(merge_timeline, dict)
|
|
857
|
+
else None
|
|
858
|
+
)
|
|
859
|
+
if not isinstance(merge_nodes, list):
|
|
860
|
+
raise ArchiveError("GitHub pull-request merge provenance is invalid")
|
|
861
|
+
if merge_commit is None:
|
|
862
|
+
if merge_nodes:
|
|
863
|
+
raise ArchiveError("open pull request has invalid merge provenance")
|
|
864
|
+
return
|
|
865
|
+
if len(merge_nodes) != 1:
|
|
866
|
+
raise ArchiveError("pull request was not merged by a GitHub merge action")
|
|
867
|
+
merge_node = merge_nodes[0]
|
|
868
|
+
commit = merge_node.get("commit") if isinstance(merge_node, dict) else None
|
|
869
|
+
actor = merge_node.get("actor") if isinstance(merge_node, dict) else None
|
|
870
|
+
if (
|
|
871
|
+
not isinstance(merge_node, dict)
|
|
872
|
+
or merge_node.get("__typename") != "MergedEvent"
|
|
873
|
+
or not isinstance(commit, dict)
|
|
874
|
+
or commit.get("oid") != merge_commit
|
|
875
|
+
):
|
|
876
|
+
raise ArchiveError("GitHub pull-request merge action does not match landing")
|
|
877
|
+
if (
|
|
878
|
+
not isinstance(actor, dict)
|
|
879
|
+
or actor.get("__typename") != "User"
|
|
880
|
+
or not isinstance(actor.get("login"), str)
|
|
881
|
+
or not actor["login"]
|
|
882
|
+
):
|
|
883
|
+
raise ArchiveError("pull request must be merged by a human GitHub user")
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def integrate_pull_request(
|
|
887
|
+
project: Path,
|
|
888
|
+
state: PipelineState,
|
|
889
|
+
archive_path: str,
|
|
890
|
+
archive_name: str,
|
|
891
|
+
ship_commit: str,
|
|
892
|
+
) -> dict:
|
|
893
|
+
branch = state.branch
|
|
894
|
+
if branch is None:
|
|
895
|
+
raise ArchiveError("pull-request integration requires a bound branch")
|
|
896
|
+
require_github_authentication()
|
|
897
|
+
repository = github_repository(project)
|
|
898
|
+
pull = find_pull_request(repository, branch, ship_commit)
|
|
899
|
+
if pull is None:
|
|
900
|
+
publish_bound_branch(project, branch, ship_commit)
|
|
901
|
+
pull = create_pull_request(
|
|
902
|
+
repository,
|
|
903
|
+
branch,
|
|
904
|
+
archive_path,
|
|
905
|
+
archive_name,
|
|
906
|
+
ship_commit,
|
|
907
|
+
)
|
|
908
|
+
elif pull["merged_at"] is None:
|
|
909
|
+
if pull["state"] != "open":
|
|
910
|
+
raise ArchiveError("GitHub pull request was closed without merging")
|
|
911
|
+
pull = update_pull_request_body(
|
|
912
|
+
repository,
|
|
913
|
+
pull,
|
|
914
|
+
archive_path,
|
|
915
|
+
ship_commit,
|
|
916
|
+
branch,
|
|
917
|
+
)
|
|
918
|
+
if pull["merged_at"] is None:
|
|
919
|
+
if pull["state"] != "open":
|
|
920
|
+
raise ArchiveError("GitHub pull request was closed without merging")
|
|
921
|
+
publish_bound_branch(project, branch, ship_commit)
|
|
922
|
+
require_pull_request_merge_provenance(repository, pull["number"])
|
|
923
|
+
return {
|
|
924
|
+
"status": "awaiting-merge",
|
|
925
|
+
"mode": "pull-request",
|
|
926
|
+
"archive": archive_path,
|
|
927
|
+
"commit": ship_commit,
|
|
928
|
+
"pull_request": pull["html_url"],
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
merge_commit = pull["merge_commit_sha"]
|
|
932
|
+
if pull["state"] != "closed" or not isinstance(merge_commit, str):
|
|
933
|
+
raise ArchiveError("GitHub pull request merge metadata is invalid")
|
|
934
|
+
require_pull_request_merge_provenance(
|
|
935
|
+
repository,
|
|
936
|
+
pull["number"],
|
|
937
|
+
merge_commit,
|
|
938
|
+
)
|
|
939
|
+
remote_default = refresh_origin(project)["remote_default"]
|
|
940
|
+
require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
|
|
941
|
+
pull = update_pull_request_body(
|
|
942
|
+
repository,
|
|
943
|
+
pull,
|
|
944
|
+
archive_path,
|
|
945
|
+
ship_commit,
|
|
946
|
+
branch,
|
|
947
|
+
)
|
|
948
|
+
tag_name = f"milestone/{archive_name}"
|
|
949
|
+
message = pull_request_tag_message(
|
|
950
|
+
archive_name,
|
|
951
|
+
pull["html_url"],
|
|
952
|
+
ship_commit,
|
|
953
|
+
merge_commit,
|
|
954
|
+
)
|
|
955
|
+
tag_object, tag_published = ensure_integration_tag(
|
|
956
|
+
project,
|
|
957
|
+
tag_name,
|
|
958
|
+
merge_commit,
|
|
959
|
+
message,
|
|
960
|
+
)
|
|
961
|
+
if not tag_published:
|
|
962
|
+
archive_milestone.require_git_success(
|
|
963
|
+
run_git(
|
|
964
|
+
project,
|
|
965
|
+
"push",
|
|
966
|
+
"origin",
|
|
967
|
+
f"refs/tags/{tag_name}:refs/tags/{tag_name}",
|
|
968
|
+
),
|
|
969
|
+
"push milestone tag",
|
|
970
|
+
)
|
|
971
|
+
archive_milestone.require_git_success(
|
|
972
|
+
run_git(
|
|
973
|
+
project,
|
|
974
|
+
"update-ref",
|
|
975
|
+
f"refs/remotes/origin/tags/{tag_name}",
|
|
976
|
+
tag_object,
|
|
977
|
+
),
|
|
978
|
+
"refresh local milestone-tag ref",
|
|
979
|
+
)
|
|
980
|
+
return validate_integrated(project, archive_milestone.milestone_slug(state))
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def integrate(repo: Path, slug: str) -> dict:
|
|
984
|
+
project = repo.resolve()
|
|
985
|
+
active_root = archive_milestone.require_project_layout(project)
|
|
986
|
+
state, _, loaded_state_path = archive_milestone.strict_state(project)
|
|
987
|
+
if loaded_state_path.resolve() != (active_root / "STATE.md").resolve():
|
|
988
|
+
raise ArchiveError("strict STATE.md loader returned an unexpected path")
|
|
989
|
+
expected_slug = archive_milestone.milestone_slug(state)
|
|
990
|
+
if archive_milestone.normalized_slug(slug) != expected_slug:
|
|
991
|
+
raise ArchiveError(
|
|
992
|
+
f"--slug {slug!r} does not match normalized STATE.milestone {expected_slug!r}"
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
shipped = archive_milestone.validate(project)
|
|
996
|
+
archive_path = shipped["archive"]
|
|
997
|
+
ship_commit = shipped["commit"]
|
|
998
|
+
archive_name = PurePosixPath(archive_path).name
|
|
999
|
+
bound_branch = state.branch
|
|
1000
|
+
expected_branch = bound_branch_name(milestone_number(archive_name))
|
|
1001
|
+
if bound_branch != expected_branch:
|
|
1002
|
+
raise ArchiveError(f"integration requires canonical bound branch {expected_branch}")
|
|
1003
|
+
head = archive_milestone.require_git_success(
|
|
1004
|
+
run_git(project, "rev-parse", "HEAD"), "resolve bound branch HEAD"
|
|
1005
|
+
)
|
|
1006
|
+
if head != ship_commit:
|
|
1007
|
+
raise ArchiveError("bound worktree must remain at the ship commit during integration")
|
|
1008
|
+
subject = archive_milestone.require_git_success(
|
|
1009
|
+
run_git(project, "show", "-s", "--format=%s", ship_commit),
|
|
1010
|
+
"inspect ship commit subject",
|
|
1011
|
+
)
|
|
1012
|
+
if subject != ship_subject(archive_name):
|
|
1013
|
+
raise ArchiveError("integration only generates canonical M00N shipment history")
|
|
1014
|
+
|
|
1015
|
+
remote_default = refresh_origin(project)["remote_default"]
|
|
1016
|
+
default_name = default_branch_name(remote_default)
|
|
1017
|
+
if default_name != "main":
|
|
1018
|
+
raise ArchiveError(f"remote default must be main, got {default_name!r}")
|
|
1019
|
+
if bound_branch == default_name:
|
|
1020
|
+
raise ArchiveError("bound branch must not be the remote default")
|
|
1021
|
+
remote_default_sha = archive_milestone.require_git_success(
|
|
1022
|
+
run_git(project, "rev-parse", remote_default), "resolve remote default"
|
|
1023
|
+
)
|
|
1024
|
+
if state.integration == "pull-request":
|
|
1025
|
+
return integrate_pull_request(
|
|
1026
|
+
project,
|
|
1027
|
+
state,
|
|
1028
|
+
archive_path,
|
|
1029
|
+
archive_name,
|
|
1030
|
+
ship_commit,
|
|
1031
|
+
)
|
|
1032
|
+
publishable_bound_branch(project, bound_branch, ship_commit)
|
|
1033
|
+
integration_branch, worktree = integration_names(project, archive_name)
|
|
1034
|
+
interrupted_worktree = registered_worktree(project, integration_branch)
|
|
1035
|
+
if (
|
|
1036
|
+
interrupted_worktree is not None
|
|
1037
|
+
and optional_ref(interrupted_worktree, "MERGE_HEAD") is not None
|
|
1038
|
+
):
|
|
1039
|
+
if interrupted_worktree != worktree.resolve():
|
|
1040
|
+
raise ArchiveError(
|
|
1041
|
+
f"integration branch {integration_branch} is checked out at unexpected path: "
|
|
1042
|
+
f"{interrupted_worktree}"
|
|
1043
|
+
)
|
|
1044
|
+
conflicts = run_git(
|
|
1045
|
+
interrupted_worktree, "diff", "--name-only", "--diff-filter=U"
|
|
1046
|
+
).stdout.strip()
|
|
1047
|
+
archive_milestone.require_git_success(
|
|
1048
|
+
run_git(interrupted_worktree, "merge", "--abort"),
|
|
1049
|
+
"abort interrupted integration merge",
|
|
1050
|
+
)
|
|
1051
|
+
remove_registered_worktree(project, integration_branch, worktree)
|
|
1052
|
+
interrupted_tip = optional_ref(project, f"refs/heads/{integration_branch}")
|
|
1053
|
+
if interrupted_tip is not None:
|
|
1054
|
+
delete_integration_branch(project, integration_branch, interrupted_tip)
|
|
1055
|
+
raise ArchiveError(
|
|
1056
|
+
"interrupted integration merge conflicted; Git aborted it without resolving files: "
|
|
1057
|
+
+ ", ".join(conflicts.splitlines())
|
|
1058
|
+
+ "; closed milestone remains unchanged; run gsd-path-forensics and "
|
|
1059
|
+
"obtain a user-approved integration recovery plan; do not merge into or re-ship the bound branch"
|
|
1060
|
+
)
|
|
1061
|
+
remove_registered_worktree(project, integration_branch, worktree)
|
|
1062
|
+
worktree_active = False
|
|
1063
|
+
|
|
1064
|
+
contains_ship = run_git(
|
|
1065
|
+
project, "merge-base", "--is-ancestor", ship_commit, remote_default
|
|
1066
|
+
)
|
|
1067
|
+
if contains_ship.returncode == 0:
|
|
1068
|
+
merge_commit = find_integrate_commit(
|
|
1069
|
+
project, remote_default, archive_name, ship_commit
|
|
1070
|
+
)
|
|
1071
|
+
require_generated_integration_commit(
|
|
1072
|
+
project,
|
|
1073
|
+
merge_commit,
|
|
1074
|
+
archive_path,
|
|
1075
|
+
archive_name,
|
|
1076
|
+
ship_commit,
|
|
1077
|
+
default_name,
|
|
1078
|
+
bound_branch,
|
|
1079
|
+
)
|
|
1080
|
+
elif contains_ship.returncode == 1:
|
|
1081
|
+
merge_commit, worktree_active = create_integration_merge(
|
|
1082
|
+
project,
|
|
1083
|
+
worktree,
|
|
1084
|
+
integration_branch,
|
|
1085
|
+
remote_default_sha,
|
|
1086
|
+
archive_path,
|
|
1087
|
+
archive_name,
|
|
1088
|
+
ship_commit,
|
|
1089
|
+
default_name,
|
|
1090
|
+
bound_branch,
|
|
1091
|
+
)
|
|
1092
|
+
else:
|
|
1093
|
+
archive_milestone.require_git_success(contains_ship, "inspect ship ancestry on the remote default")
|
|
1094
|
+
raise AssertionError("unreachable")
|
|
1095
|
+
|
|
1096
|
+
action_error = None
|
|
1097
|
+
try:
|
|
1098
|
+
tag_name = f"milestone/{archive_name}"
|
|
1099
|
+
tag_object, tag_published = ensure_integration_tag(
|
|
1100
|
+
project, tag_name, merge_commit
|
|
1101
|
+
)
|
|
1102
|
+
|
|
1103
|
+
published_merge = run_git(
|
|
1104
|
+
project, "merge-base", "--is-ancestor", merge_commit, remote_default
|
|
1105
|
+
)
|
|
1106
|
+
if published_merge.returncode == 1:
|
|
1107
|
+
archive_milestone.require_git_success(
|
|
1108
|
+
run_git(
|
|
1109
|
+
project,
|
|
1110
|
+
"push",
|
|
1111
|
+
"origin",
|
|
1112
|
+
f"{merge_commit}:refs/heads/{default_name}",
|
|
1113
|
+
),
|
|
1114
|
+
"push integration merge to main",
|
|
1115
|
+
)
|
|
1116
|
+
archive_milestone.require_git_success(
|
|
1117
|
+
run_git(project, "update-ref", f"refs/remotes/{remote_default}", merge_commit),
|
|
1118
|
+
"refresh local remote-default ref",
|
|
1119
|
+
)
|
|
1120
|
+
elif published_merge.returncode != 0:
|
|
1121
|
+
archive_milestone.require_git_success(published_merge, "inspect published integration merge")
|
|
1122
|
+
|
|
1123
|
+
publish_bound_branch(project, bound_branch, ship_commit)
|
|
1124
|
+
if not tag_published:
|
|
1125
|
+
archive_milestone.require_git_success(
|
|
1126
|
+
run_git(
|
|
1127
|
+
project,
|
|
1128
|
+
"push",
|
|
1129
|
+
"origin",
|
|
1130
|
+
f"refs/tags/{tag_name}:refs/tags/{tag_name}",
|
|
1131
|
+
),
|
|
1132
|
+
"push milestone tag",
|
|
1133
|
+
)
|
|
1134
|
+
archive_milestone.require_git_success(
|
|
1135
|
+
run_git(
|
|
1136
|
+
project,
|
|
1137
|
+
"update-ref",
|
|
1138
|
+
f"refs/remotes/origin/tags/{tag_name}",
|
|
1139
|
+
tag_object,
|
|
1140
|
+
),
|
|
1141
|
+
"refresh local milestone-tag ref",
|
|
1142
|
+
)
|
|
1143
|
+
except (ArchiveError, OSError) as error:
|
|
1144
|
+
action_error = error
|
|
1145
|
+
|
|
1146
|
+
try:
|
|
1147
|
+
if worktree_active:
|
|
1148
|
+
remove_registered_worktree(project, integration_branch, worktree)
|
|
1149
|
+
except (ArchiveError, OSError) as cleanup_error:
|
|
1150
|
+
if action_error is not None:
|
|
1151
|
+
raise ArchiveError(
|
|
1152
|
+
f"{action_error}; integration worktree cleanup failed: {cleanup_error}"
|
|
1153
|
+
)
|
|
1154
|
+
raise
|
|
1155
|
+
if action_error is not None:
|
|
1156
|
+
raise action_error
|
|
1157
|
+
|
|
1158
|
+
branch_tip = optional_ref(project, f"refs/heads/{integration_branch}")
|
|
1159
|
+
if branch_tip is not None:
|
|
1160
|
+
if branch_tip != merge_commit:
|
|
1161
|
+
ancestor = run_git(
|
|
1162
|
+
project, "merge-base", "--is-ancestor", branch_tip, merge_commit
|
|
1163
|
+
)
|
|
1164
|
+
if ancestor.returncode != 0:
|
|
1165
|
+
raise ArchiveError(f"integration branch {integration_branch} moved unexpectedly")
|
|
1166
|
+
delete_integration_branch(project, integration_branch, branch_tip)
|
|
1167
|
+
return validate_integrated(project, slug)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def find_integrate_commit(
|
|
1171
|
+
project: Path,
|
|
1172
|
+
remote_default: str,
|
|
1173
|
+
archive_name: str,
|
|
1174
|
+
ship_commit: str,
|
|
1175
|
+
) -> str:
|
|
1176
|
+
default_name = default_branch_name(remote_default)
|
|
1177
|
+
expected_subject = integrate_subject(archive_name, default_name)
|
|
1178
|
+
log = archive_milestone.require_git_success(
|
|
1179
|
+
run_git(project, "log", "--first-parent", "--format=%H%x00%s", remote_default),
|
|
1180
|
+
f"inspect {remote_default} first-parent history for the integration merge",
|
|
1181
|
+
)
|
|
1182
|
+
matches = []
|
|
1183
|
+
for record in log.splitlines():
|
|
1184
|
+
if "\x00" not in record:
|
|
1185
|
+
continue
|
|
1186
|
+
commit, subject = record.split("\x00", 1)
|
|
1187
|
+
if subject == expected_subject:
|
|
1188
|
+
matches.append(commit)
|
|
1189
|
+
if not matches:
|
|
1190
|
+
raise ArchiveError(
|
|
1191
|
+
f"no commit with exact subject {expected_subject!r} "
|
|
1192
|
+
f"in {remote_default} first-parent history"
|
|
1193
|
+
)
|
|
1194
|
+
if len(matches) != 1:
|
|
1195
|
+
raise ArchiveError(
|
|
1196
|
+
f"multiple commits with exact subject {expected_subject!r} "
|
|
1197
|
+
f"in {remote_default} first-parent history"
|
|
1198
|
+
)
|
|
1199
|
+
merge_commit = matches[0]
|
|
1200
|
+
parents = archive_milestone.require_git_success(
|
|
1201
|
+
run_git(project, "rev-list", "--parents", "-n", "1", merge_commit),
|
|
1202
|
+
"inspect integration merge parents",
|
|
1203
|
+
).split()
|
|
1204
|
+
if len(parents) != 3:
|
|
1205
|
+
raise ArchiveError(f"integration commit for {archive_name} is not a merge commit")
|
|
1206
|
+
if parents[2] != ship_commit:
|
|
1207
|
+
raise ArchiveError("integration merge second parent is not the ship commit")
|
|
1208
|
+
return merge_commit
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def validate_integrated(repo: Path, slug: str) -> dict:
|
|
1212
|
+
project = repo.resolve()
|
|
1213
|
+
active_root = archive_milestone.require_project_layout(project)
|
|
1214
|
+
state, _, loaded_state_path = archive_milestone.strict_state(project)
|
|
1215
|
+
if loaded_state_path.resolve() != (active_root / "STATE.md").resolve():
|
|
1216
|
+
raise ArchiveError("strict STATE.md loader returned an unexpected path")
|
|
1217
|
+
expected_slug = archive_milestone.milestone_slug(state)
|
|
1218
|
+
if archive_milestone.normalized_slug(slug) != expected_slug:
|
|
1219
|
+
raise ArchiveError(
|
|
1220
|
+
f"--slug {slug!r} does not match normalized STATE.milestone {expected_slug!r}"
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
# (a) The shipped transaction itself must still validate.
|
|
1224
|
+
shipped = archive_milestone.validate(repo)
|
|
1225
|
+
configured = shipped["archive"]
|
|
1226
|
+
ship_commit = shipped["commit"]
|
|
1227
|
+
archive_name = PurePosixPath(configured).name
|
|
1228
|
+
|
|
1229
|
+
# Refresh whenever origin exists so direct mode cannot miss a published tag.
|
|
1230
|
+
# Fixtures that only plant refs/remotes/origin/* keep resolve_remote_default.
|
|
1231
|
+
if run_git(project, "remote", "get-url", "origin").returncode == 0:
|
|
1232
|
+
remote_default = refresh_origin(project)["remote_default"]
|
|
1233
|
+
else:
|
|
1234
|
+
remote_default = resolve_remote_default(project)
|
|
1235
|
+
default_name = default_branch_name(remote_default)
|
|
1236
|
+
bound_branch = state.branch
|
|
1237
|
+
if bound_branch is None:
|
|
1238
|
+
raise ArchiveError("STATE.md does not name a bound build branch")
|
|
1239
|
+
expected_branch = bound_branch_name(milestone_number(archive_name))
|
|
1240
|
+
ship_subject_text = archive_milestone.require_git_success(
|
|
1241
|
+
run_git(project, "log", "-1", "--format=%s", ship_commit),
|
|
1242
|
+
"read ship commit subject",
|
|
1243
|
+
)
|
|
1244
|
+
if ship_subject_text != ship_subject(archive_name):
|
|
1245
|
+
raise ArchiveError("current milestone ship commit subject is not canonical")
|
|
1246
|
+
if not is_bound_branch(bound_branch):
|
|
1247
|
+
raise ArchiveError(f"bound branch {bound_branch!r} is not gsd-path/M00N")
|
|
1248
|
+
if is_bound_branch(bound_branch) and bound_branch != expected_branch:
|
|
1249
|
+
expected_milestone = expected_branch.removeprefix("gsd-path/")
|
|
1250
|
+
raise ArchiveError(
|
|
1251
|
+
f"bound branch {bound_branch} does not match archive milestone {expected_milestone}"
|
|
1252
|
+
)
|
|
1253
|
+
if bound_branch == default_name:
|
|
1254
|
+
raise ArchiveError(
|
|
1255
|
+
f"bound branch {bound_branch!r} is the remote default; "
|
|
1256
|
+
"ship merges onto main, never onto the work branch"
|
|
1257
|
+
)
|
|
1258
|
+
if default_name != "main":
|
|
1259
|
+
raise ArchiveError(f"remote default must be main, got {default_name!r}")
|
|
1260
|
+
|
|
1261
|
+
# (b) Resolve the integration proof selected before build.
|
|
1262
|
+
tag_name = f"milestone/{archive_name}"
|
|
1263
|
+
tag_ref = f"refs/tags/{tag_name}"
|
|
1264
|
+
pull_request = None
|
|
1265
|
+
if state.integration == "pull-request":
|
|
1266
|
+
if run_git(project, "rev-parse", "--verify", "--quiet", tag_ref).returncode != 0:
|
|
1267
|
+
raise ArchiveError(f"missing milestone tag: {tag_name}")
|
|
1268
|
+
metadata = pull_request_tag_metadata(project, tag_ref)
|
|
1269
|
+
if metadata["Ship"] != ship_commit:
|
|
1270
|
+
raise ArchiveError("pull-request milestone tag names the wrong ship commit")
|
|
1271
|
+
merge_commit = metadata["Landing"]
|
|
1272
|
+
pull_request = metadata["Pull-Request"]
|
|
1273
|
+
require_github_authentication()
|
|
1274
|
+
repository = github_repository(project)
|
|
1275
|
+
pull = find_pull_request(repository, bound_branch, ship_commit)
|
|
1276
|
+
if pull is None:
|
|
1277
|
+
raise ArchiveError("GitHub pull request for the ship commit is missing")
|
|
1278
|
+
if pull["html_url"] != pull_request:
|
|
1279
|
+
raise ArchiveError("milestone tag names the wrong pull request")
|
|
1280
|
+
if (
|
|
1281
|
+
pull["state"] != "closed"
|
|
1282
|
+
or pull["merged_at"] is None
|
|
1283
|
+
or pull["merge_commit_sha"] != merge_commit
|
|
1284
|
+
):
|
|
1285
|
+
raise ArchiveError("GitHub pull request is not merged at the tagged landing")
|
|
1286
|
+
require_pull_request_merge_provenance(
|
|
1287
|
+
repository,
|
|
1288
|
+
pull["number"],
|
|
1289
|
+
merge_commit,
|
|
1290
|
+
)
|
|
1291
|
+
require_pull_request_merge(project, merge_commit, ship_commit, remote_default)
|
|
1292
|
+
require_annotated_tag(
|
|
1293
|
+
project,
|
|
1294
|
+
tag_ref,
|
|
1295
|
+
merge_commit,
|
|
1296
|
+
f"milestone tag {tag_name}",
|
|
1297
|
+
pull_request_tag_message(
|
|
1298
|
+
archive_name,
|
|
1299
|
+
pull_request,
|
|
1300
|
+
ship_commit,
|
|
1301
|
+
merge_commit,
|
|
1302
|
+
),
|
|
1303
|
+
)
|
|
1304
|
+
else:
|
|
1305
|
+
merge_commit = find_integrate_commit(
|
|
1306
|
+
project, remote_default, archive_name, ship_commit
|
|
1307
|
+
)
|
|
1308
|
+
archive_milestone.require_canonical_commit_body(
|
|
1309
|
+
project,
|
|
1310
|
+
merge_commit,
|
|
1311
|
+
integrate_subject(archive_name, default_name),
|
|
1312
|
+
integrate_commit_body(configured, ship_commit, default_name, bound_branch),
|
|
1313
|
+
"integration",
|
|
1314
|
+
)
|
|
1315
|
+
if run_git(project, "rev-parse", "--verify", "--quiet", tag_ref).returncode != 0:
|
|
1316
|
+
raise ArchiveError(f"missing milestone tag: {tag_name}")
|
|
1317
|
+
require_annotated_tag(
|
|
1318
|
+
project,
|
|
1319
|
+
tag_ref,
|
|
1320
|
+
merge_commit,
|
|
1321
|
+
f"milestone tag {tag_name}",
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
# (c) The remote default must contain the merge commit.
|
|
1325
|
+
contains = run_git(project, "merge-base", "--is-ancestor", merge_commit, remote_default)
|
|
1326
|
+
if contains.returncode != 0:
|
|
1327
|
+
raise ArchiveError(f"{remote_default} does not contain the integration merge")
|
|
1328
|
+
|
|
1329
|
+
# (e) The bound branch tip and annotated tag must be published on origin.
|
|
1330
|
+
require_published_integration(
|
|
1331
|
+
project,
|
|
1332
|
+
bound_branch,
|
|
1333
|
+
ship_commit,
|
|
1334
|
+
tag_name,
|
|
1335
|
+
merge_commit,
|
|
1336
|
+
allow_missing_bound=state.integration == "pull-request",
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
result = {
|
|
1340
|
+
"archive": configured,
|
|
1341
|
+
"commit": ship_commit,
|
|
1342
|
+
"integrate": merge_commit,
|
|
1343
|
+
"landing": merge_commit,
|
|
1344
|
+
"base": archive_milestone.require_git_success(
|
|
1345
|
+
run_git(project, "rev-parse", remote_default),
|
|
1346
|
+
"resolve integrated remote default",
|
|
1347
|
+
),
|
|
1348
|
+
"mode": state.integration,
|
|
1349
|
+
"tag": tag_name,
|
|
1350
|
+
}
|
|
1351
|
+
if pull_request is not None:
|
|
1352
|
+
result["pull_request"] = pull_request
|
|
1353
|
+
return result
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def require_published_integration(
|
|
1357
|
+
project: Path,
|
|
1358
|
+
bound_branch: str,
|
|
1359
|
+
ship_commit: str,
|
|
1360
|
+
tag_name: str,
|
|
1361
|
+
merge_commit: str,
|
|
1362
|
+
allow_missing_bound: bool = False,
|
|
1363
|
+
) -> None:
|
|
1364
|
+
if allow_missing_bound:
|
|
1365
|
+
published_ship = live_remote_ref(project, f"refs/heads/{bound_branch}")
|
|
1366
|
+
if published_ship is not None and published_ship != ship_commit:
|
|
1367
|
+
raise ArchiveError(
|
|
1368
|
+
f"origin/{bound_branch} is {published_ship}, expected ship commit {ship_commit}"
|
|
1369
|
+
)
|
|
1370
|
+
published_tag = live_remote_ref(project, f"refs/tags/{tag_name}")
|
|
1371
|
+
if published_tag is None:
|
|
1372
|
+
raise ArchiveError(f"missing published milestone tag: origin/tags/{tag_name}")
|
|
1373
|
+
local_tag = optional_ref(project, f"refs/tags/{tag_name}")
|
|
1374
|
+
if local_tag != published_tag:
|
|
1375
|
+
raise ArchiveError(f"local and published milestone tag {tag_name} differ")
|
|
1376
|
+
require_annotated_tag(
|
|
1377
|
+
project,
|
|
1378
|
+
published_tag,
|
|
1379
|
+
merge_commit,
|
|
1380
|
+
f"published milestone tag {tag_name}",
|
|
1381
|
+
)
|
|
1382
|
+
return
|
|
1383
|
+
|
|
1384
|
+
bound_ref = f"refs/remotes/origin/{bound_branch}"
|
|
1385
|
+
if run_git(project, "rev-parse", "--verify", "--quiet", bound_ref).returncode != 0:
|
|
1386
|
+
if not allow_missing_bound:
|
|
1387
|
+
raise ArchiveError(f"missing published bound branch: origin/{bound_branch}")
|
|
1388
|
+
else:
|
|
1389
|
+
published_ship = archive_milestone.require_git_success(
|
|
1390
|
+
run_git(project, "rev-parse", bound_ref),
|
|
1391
|
+
"resolve published bound branch",
|
|
1392
|
+
)
|
|
1393
|
+
if published_ship != ship_commit:
|
|
1394
|
+
raise ArchiveError(
|
|
1395
|
+
f"origin/{bound_branch} is {published_ship}, expected ship commit {ship_commit}"
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
remote_tag = f"refs/remotes/origin/tags/{tag_name}"
|
|
1399
|
+
if run_git(project, "rev-parse", "--verify", "--quiet", remote_tag).returncode != 0:
|
|
1400
|
+
raise ArchiveError(f"missing published milestone tag: origin/tags/{tag_name}")
|
|
1401
|
+
remote_tag_type = archive_milestone.require_git_success(
|
|
1402
|
+
run_git(project, "cat-file", "-t", remote_tag),
|
|
1403
|
+
"inspect published milestone tag type",
|
|
1404
|
+
)
|
|
1405
|
+
if remote_tag_type != "tag":
|
|
1406
|
+
raise ArchiveError(f"published milestone tag {tag_name} must be annotated")
|
|
1407
|
+
remote_tag_target = archive_milestone.require_git_success(
|
|
1408
|
+
run_git(project, "rev-parse", f"{remote_tag}^{{commit}}"),
|
|
1409
|
+
"resolve published milestone tag target",
|
|
1410
|
+
)
|
|
1411
|
+
if remote_tag_target != merge_commit:
|
|
1412
|
+
raise ArchiveError(
|
|
1413
|
+
f"published milestone tag {tag_name} does not point at the integration merge"
|
|
1414
|
+
)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
# archive_milestone imports this module, so the parent is resolved after the
|
|
1418
|
+
# definitions above. Parent functions are looked up on the module at call time
|
|
1419
|
+
# so patches applied to archive_milestone stay visible here.
|
|
1420
|
+
if __package__: # imported as scripts.integration
|
|
1421
|
+
from . import archive_milestone
|
|
1422
|
+
from .archive_milestone import (
|
|
1423
|
+
GITHUB_HOST,
|
|
1424
|
+
PR_CREDIT_LINE,
|
|
1425
|
+
ArchiveError,
|
|
1426
|
+
)
|
|
1427
|
+
else: # standalone script or sibling import
|
|
1428
|
+
import archive_milestone
|
|
1429
|
+
from archive_milestone import (
|
|
1430
|
+
GITHUB_HOST,
|
|
1431
|
+
PR_CREDIT_LINE,
|
|
1432
|
+
ArchiveError,
|
|
1433
|
+
)
|