@deftai/directive-content 0.58.0 → 0.60.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/.githooks/pre-push +10 -9
- package/Taskfile.yml +57 -67
- package/UPGRADING.md +1 -1
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/rules/rules-pack-0.1.json +3 -3
- package/packs/skills/skills-pack-0.1.json +22 -22
- package/scm/github.md +20 -2
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +16 -0
- package/tasks/relocate.yml +18 -48
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -2
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2551
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
package/scripts/release_e2e.py
DELETED
|
@@ -1,1011 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""release_e2e.py -- Auto-create + auto-destroy temp-repo release rehearsal (#716, #720).
|
|
3
|
-
|
|
4
|
-
Companion to ``scripts/release.py`` per the #716 safety hardening Q1
|
|
5
|
-
decision (auto-create + auto-destroy temp repo). ``task release:e2e``
|
|
6
|
-
provisions a private GitHub repo named
|
|
7
|
-
``deftai-release-test-<timestamp>-<uuid6>``, runs the full release
|
|
8
|
-
pipeline against it, then destroys the repo via ``gh repo delete --yes``
|
|
9
|
-
in a ``try/finally`` so cleanup runs even when the test fails.
|
|
10
|
-
|
|
11
|
-
Pipeline (#720 deepening) and env hygiene (#728 cycle 2)
|
|
12
|
-
--------------------------------------------------------
|
|
13
|
-
The rehearsal step was previously a smoke-test existence check
|
|
14
|
-
(``gh repo view``); per #720 it now mirrors the directive repo into
|
|
15
|
-
the temp remote and exercises the actual ``task release`` pipeline
|
|
16
|
-
end-to-end. Per the #728 cycle-2 Greptile review, every subprocess
|
|
17
|
-
that could resolve a project root (``clone_repo_to_temp``,
|
|
18
|
-
``dispatch_task_release``, ``dispatch_task_release_rollback``) must
|
|
19
|
-
also pin ``DEFT_PROJECT_ROOT=<clone_dir>`` so an operator with that
|
|
20
|
-
environment variable already exported in their shell does NOT have
|
|
21
|
-
``task release`` resolve back to the real directive repo and push
|
|
22
|
-
spurious ``v0.0.1`` artefacts to ``deftai/directive``.
|
|
23
|
-
|
|
24
|
-
1. Generate a unique repo slug (``deftai-release-test-<timestamp>-<uuid6>``)
|
|
25
|
-
2. ``gh repo create --private deftai/<slug> --description "..."``
|
|
26
|
-
3. Mirror the current directive repo into the temp remote and exercise
|
|
27
|
-
the release pipeline:
|
|
28
|
-
|
|
29
|
-
a. ``git clone <project_root> <tmpdir>`` -- shallow-style local clone
|
|
30
|
-
(operates on the on-disk repo so we do not depend on network).
|
|
31
|
-
b. ``git -C <tmpdir> remote set-url origin <temp-repo-url>`` -- point
|
|
32
|
-
origin at the auto-created temp remote.
|
|
33
|
-
c. ``git -C <tmpdir> push origin refs/heads/*:refs/heads/*
|
|
34
|
-
refs/tags/*:refs/tags/*`` -- populate the temp remote with every
|
|
35
|
-
branch and tag using explicit refspecs. We deliberately avoid
|
|
36
|
-
``git push --mirror`` here because ``--mirror`` also pushes
|
|
37
|
-
``refs/remotes/*`` (the local clone's remote-tracking refs);
|
|
38
|
-
GitHub's receive-pack rejects writes to that namespace and the
|
|
39
|
-
whole rehearsal would fail at the push step. Explicit refspecs
|
|
40
|
-
cover the two namespaces we actually care about (heads + tags)
|
|
41
|
-
without leaking remote-tracking refs.
|
|
42
|
-
d. ``task release -- 0.0.1 --repo deftai/<slug> --skip-ci --skip-build``
|
|
43
|
-
-- run the full 10-step pipeline against the temp repo. ``--skip-ci``
|
|
44
|
-
and ``--skip-build`` (#720, see ``scripts/release.py``) keep the
|
|
45
|
-
wall-clock manageable; CI / build semantics are covered by the
|
|
46
|
-
unit-test suite at every commit on master.
|
|
47
|
-
e. ``gh release view v0.0.1 --repo deftai/<slug>`` -- assert
|
|
48
|
-
``isDraft=true`` and ``tagName == v0.0.1`` (the production draft
|
|
49
|
-
lifecycle, #716).
|
|
50
|
-
f. ``git -C <tmpdir> ls-remote --tags origin v0.0.1`` -- assert the
|
|
51
|
-
tag exists on the temp remote.
|
|
52
|
-
g. ``rehearse_npm_publish`` (#1910) -- mirror ``npm-publish.yml`` against
|
|
53
|
-
the clone: ``pnpm install`` + ``pnpm -w run build``, align the four
|
|
54
|
-
``@deftai/directive*`` ``package.json`` versions + resolve the
|
|
55
|
-
``workspace:`` protocol, then ``npm publish --dry-run --access public
|
|
56
|
-
--tag e2e-rehearsal`` per package in dependency order (types -> core ->
|
|
57
|
-
content -> cli). This
|
|
58
|
-
catches a broken ``files`` allowlist, version drift, or dependency-order
|
|
59
|
-
bug BEFORE a real ``v*`` tag fires the publish workflow, without ever
|
|
60
|
-
touching the real registry. Soft-skips when ``npm`` is absent from PATH;
|
|
61
|
-
suppressed entirely by ``--skip-npm`` (install+build exceed the <90s
|
|
62
|
-
fast budget that ``--skip-ci``/``--skip-build`` protect).
|
|
63
|
-
h. ``task release:rollback -- 0.0.1 --repo deftai/<slug>`` -- exercise
|
|
64
|
-
the rollback path against a known-state release (#725 forward-revert
|
|
65
|
-
flow on a protected default branch).
|
|
66
|
-
|
|
67
|
-
4. ``gh repo delete deftai/<slug> --yes`` -- ALWAYS in a finally clause
|
|
68
|
-
5. If delete fails, surface a one-line manual cleanup hint and continue
|
|
69
|
-
so the test result still reaches stdout
|
|
70
|
-
|
|
71
|
-
Wall-clock (#720)
|
|
72
|
-
-----------------
|
|
73
|
-
``--skip-ci`` and ``--skip-build`` keep the rehearsal wall-clock under
|
|
74
|
-
90 seconds on a typical operator machine. Skipping these is safe inside
|
|
75
|
-
the rehearsal because (a) CI runs at every commit on master via
|
|
76
|
-
``.github/workflows/ci.yml``; (b) build artefacts are not needed for
|
|
77
|
-
the draft-release verification step; (c) the unit-test suite covers
|
|
78
|
-
both paths in isolation.
|
|
79
|
-
|
|
80
|
-
Exit codes
|
|
81
|
-
----------
|
|
82
|
-
0 -- rehearsal succeeded; cleanup succeeded (or surfaced as a warning)
|
|
83
|
-
1 -- rehearsal failed; cleanup ran regardless
|
|
84
|
-
2 -- config / argument error (gh missing, owner unset, ...)
|
|
85
|
-
|
|
86
|
-
Mockability
|
|
87
|
-
-----------
|
|
88
|
-
Every side-effecting step (``provision_temp_repo`` / ``destroy_temp_repo``
|
|
89
|
-
/ ``clone_repo_to_temp`` / ``set_origin_to_temp_repo`` / ``push_mirror``
|
|
90
|
-
/ ``dispatch_task_release`` / ``verify_draft_release`` / ``verify_tag``
|
|
91
|
-
/ ``rehearse_npm_publish`` / ``dispatch_task_release_rollback``) is an
|
|
92
|
-
isolated function so tests can replace it with a mock; CI exercises the
|
|
93
|
-
orchestration without ever cloning, pushing, or hitting real GitHub.
|
|
94
|
-
|
|
95
|
-
Refs #720 (pipeline-mirror deepening), #716 (canonical spec; safety
|
|
96
|
-
hardening Item 4 of 7), #722 (subprocess PATHEXT fix; release._resolve_gh
|
|
97
|
-
helper re-used here), #725 (forward-revert + normal push in rollback),
|
|
98
|
-
#74 (foundation), #233, #642, #635, #709, #710.
|
|
99
|
-
"""
|
|
100
|
-
|
|
101
|
-
from __future__ import annotations
|
|
102
|
-
|
|
103
|
-
import argparse
|
|
104
|
-
import contextlib
|
|
105
|
-
import datetime as _dt
|
|
106
|
-
import io
|
|
107
|
-
import json
|
|
108
|
-
import os
|
|
109
|
-
import shutil # noqa: F401 -- kept for tests that monkeypatch release_e2e.shutil.which
|
|
110
|
-
import subprocess
|
|
111
|
-
import sys
|
|
112
|
-
import tempfile
|
|
113
|
-
import threading
|
|
114
|
-
import uuid
|
|
115
|
-
from collections.abc import Callable
|
|
116
|
-
from dataclasses import dataclass
|
|
117
|
-
from pathlib import Path
|
|
118
|
-
from typing import TextIO
|
|
119
|
-
|
|
120
|
-
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
121
|
-
|
|
122
|
-
from _stdio_utf8 import reconfigure_stdio # noqa: E402
|
|
123
|
-
|
|
124
|
-
reconfigure_stdio()
|
|
125
|
-
|
|
126
|
-
import release # noqa: E402
|
|
127
|
-
import release_rollback # noqa: E402
|
|
128
|
-
|
|
129
|
-
EXIT_OK = release.EXIT_OK
|
|
130
|
-
EXIT_VIOLATION = release.EXIT_VIOLATION
|
|
131
|
-
EXIT_CONFIG_ERROR = release.EXIT_CONFIG_ERROR
|
|
132
|
-
|
|
133
|
-
DEFAULT_OWNER = "deftai"
|
|
134
|
-
REPO_SLUG_PREFIX = "deftai-release-test-"
|
|
135
|
-
|
|
136
|
-
# #720: the rehearsal version is a fixed sentinel rather than a real
|
|
137
|
-
# release version. ``0.0.1`` is far enough below any real deft release
|
|
138
|
-
# that an operator scrolling release notes can immediately recognise it
|
|
139
|
-
# as a rehearsal artefact -- and ``X.Y.Z`` matches the strict semver
|
|
140
|
-
# regex enforced by ``release._validate_version``.
|
|
141
|
-
REHEARSAL_VERSION = "0.0.1"
|
|
142
|
-
|
|
143
|
-
# #1910: dependency order for the npm publish dry-run rehearsal, mirroring
|
|
144
|
-
# .github/workflows/npm-publish.yml (types -> core -> content -> cli).
|
|
145
|
-
NPM_PUBLISH_PACKAGES = ("types", "core", "content", "cli")
|
|
146
|
-
# #1925: throwaway dist-tag so npm publish --dry-run does not try to apply
|
|
147
|
-
# ``latest`` to the fixed rehearsal sentinel ``0.0.1`` once real packages
|
|
148
|
-
# exist at a higher version on the registry.
|
|
149
|
-
NPM_E2E_REHEARSAL_TAG = "e2e-rehearsal"
|
|
150
|
-
NPM_INSTALL_TIMEOUT_SECONDS = 600
|
|
151
|
-
NPM_BUILD_TIMEOUT_SECONDS = 600
|
|
152
|
-
NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS = 180
|
|
153
|
-
|
|
154
|
-
RELEASE_ENTRYPOINT_TIMEOUT_SECONDS = 600.0
|
|
155
|
-
ROLLBACK_ENTRYPOINT_TIMEOUT_SECONDS = 300.0
|
|
156
|
-
ENTRYPOINT_TIMEOUT_EXIT_CODE = 124
|
|
157
|
-
_ENTRYPOINT_PROCESS_STATE_LOCK = threading.Lock()
|
|
158
|
-
_ENTRYPOINT_ACTIVE_RESTORE_OWNER: object | None = None
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
# ---- Data classes -----------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
@dataclass
|
|
165
|
-
class E2EConfig:
|
|
166
|
-
owner: str
|
|
167
|
-
project_root: Path
|
|
168
|
-
dry_run: bool
|
|
169
|
-
keep_repo: bool # When True, skip cleanup (manual debugging only)
|
|
170
|
-
skip_npm: bool = False # When True, skip the npm publish dry-run step (#1910)
|
|
171
|
-
# Optional override slug (test injection). If None, a fresh slug is
|
|
172
|
-
# generated per run.
|
|
173
|
-
repo_slug: str | None = None
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
# ---- argument parsing -------------------------------------------------------
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _build_parser() -> argparse.ArgumentParser:
|
|
180
|
-
parser = argparse.ArgumentParser(
|
|
181
|
-
prog="release_e2e",
|
|
182
|
-
description=(
|
|
183
|
-
"End-to-end release rehearsal against an auto-created+destroyed "
|
|
184
|
-
"temp GitHub repo (#716 safety hardening Q1)."
|
|
185
|
-
),
|
|
186
|
-
)
|
|
187
|
-
parser.add_argument(
|
|
188
|
-
"--owner",
|
|
189
|
-
default=DEFAULT_OWNER,
|
|
190
|
-
metavar="OWNER",
|
|
191
|
-
help=f"GitHub owner under which to create the temp repo (default: {DEFAULT_OWNER}).",
|
|
192
|
-
)
|
|
193
|
-
parser.add_argument(
|
|
194
|
-
"--dry-run",
|
|
195
|
-
action="store_true",
|
|
196
|
-
help="Print the pipeline plan without invoking gh.",
|
|
197
|
-
)
|
|
198
|
-
parser.add_argument(
|
|
199
|
-
"--keep-repo",
|
|
200
|
-
action="store_true",
|
|
201
|
-
help=(
|
|
202
|
-
"Skip destroying the temp repo at the end (use only when "
|
|
203
|
-
"manually debugging a failed rehearsal; remember to clean "
|
|
204
|
-
"up by hand)."
|
|
205
|
-
),
|
|
206
|
-
)
|
|
207
|
-
parser.add_argument(
|
|
208
|
-
"--project-root",
|
|
209
|
-
type=Path,
|
|
210
|
-
default=None,
|
|
211
|
-
metavar="PATH",
|
|
212
|
-
help="Repository root (default: $DEFT_PROJECT_ROOT or scripts/.. ).",
|
|
213
|
-
)
|
|
214
|
-
parser.add_argument(
|
|
215
|
-
"--skip-npm",
|
|
216
|
-
action="store_true",
|
|
217
|
-
help=(
|
|
218
|
-
"Skip the npm publish dry-run rehearsal step (#1910). The step "
|
|
219
|
-
"installs + builds the workspace, which exceeds the <90s fast "
|
|
220
|
-
"budget that --skip-ci/--skip-build protect; use this to keep the "
|
|
221
|
-
"rehearsal fast. The step also soft-skips on its own when npm is "
|
|
222
|
-
"absent from PATH."
|
|
223
|
-
),
|
|
224
|
-
)
|
|
225
|
-
return parser
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
# ---- helpers ----------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
def _emit(label: str, status: str) -> None:
|
|
232
|
-
print(f"[e2e] {label}... {status}", file=sys.stderr)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def generate_repo_slug() -> str:
|
|
236
|
-
"""Generate a unique temp repo slug.
|
|
237
|
-
|
|
238
|
-
Format: ``deftai-release-test-<YYYYMMDDHHMMSS>-<uuid6>``.
|
|
239
|
-
The timestamp aids visual sorting in `gh repo list` if cleanup ever
|
|
240
|
-
fails; the uuid6 suffix ensures uniqueness across rapid re-runs.
|
|
241
|
-
"""
|
|
242
|
-
timestamp = _dt.datetime.now(_dt.UTC).strftime("%Y%m%d%H%M%S")
|
|
243
|
-
suffix = uuid.uuid4().hex[:6]
|
|
244
|
-
return f"{REPO_SLUG_PREFIX}{timestamp}-{suffix}"
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
def provision_temp_repo(owner: str, slug: str) -> tuple[bool, str]:
|
|
248
|
-
"""Invoke ``gh repo create --private <owner>/<slug>``.
|
|
249
|
-
|
|
250
|
-
Returns ``(ok, reason)``. The remote is created empty; downstream
|
|
251
|
-
pipeline steps (clone, push, etc.) are responsible for populating
|
|
252
|
-
it.
|
|
253
|
-
"""
|
|
254
|
-
gh_path = release._resolve_gh()
|
|
255
|
-
if gh_path is None:
|
|
256
|
-
return False, "gh CLI not found on PATH"
|
|
257
|
-
full = f"{owner}/{slug}"
|
|
258
|
-
cmd = [
|
|
259
|
-
gh_path, "repo", "create", full,
|
|
260
|
-
"--private",
|
|
261
|
-
"--description", "Auto-generated release-rehearsal repo (deft #716); safe to delete.",
|
|
262
|
-
]
|
|
263
|
-
try:
|
|
264
|
-
result = subprocess.run(
|
|
265
|
-
cmd,
|
|
266
|
-
capture_output=True,
|
|
267
|
-
text=True,
|
|
268
|
-
timeout=120,
|
|
269
|
-
check=False,
|
|
270
|
-
env=os.environ.copy(),
|
|
271
|
-
)
|
|
272
|
-
except FileNotFoundError:
|
|
273
|
-
return False, "gh CLI not found on PATH"
|
|
274
|
-
if result.returncode != 0:
|
|
275
|
-
return False, f"gh repo create failed: {result.stderr.strip()}"
|
|
276
|
-
return True, f"created {full} (private)"
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def destroy_temp_repo(owner: str, slug: str) -> tuple[bool, str]:
|
|
280
|
-
"""Invoke ``gh repo delete <owner>/<slug> --yes``.
|
|
281
|
-
|
|
282
|
-
Best-effort: returns False with a diagnostic if the delete fails so
|
|
283
|
-
the caller can surface a manual cleanup hint without crashing the
|
|
284
|
-
overall pipeline.
|
|
285
|
-
"""
|
|
286
|
-
gh_path = release._resolve_gh()
|
|
287
|
-
if gh_path is None:
|
|
288
|
-
return False, "gh CLI not found on PATH"
|
|
289
|
-
full = f"{owner}/{slug}"
|
|
290
|
-
cmd = [gh_path, "repo", "delete", full, "--yes"]
|
|
291
|
-
try:
|
|
292
|
-
result = subprocess.run(
|
|
293
|
-
cmd,
|
|
294
|
-
capture_output=True,
|
|
295
|
-
text=True,
|
|
296
|
-
timeout=120,
|
|
297
|
-
check=False,
|
|
298
|
-
env=os.environ.copy(),
|
|
299
|
-
)
|
|
300
|
-
except FileNotFoundError:
|
|
301
|
-
return False, "gh CLI not found on PATH"
|
|
302
|
-
if result.returncode != 0:
|
|
303
|
-
return False, f"gh repo delete failed: {result.stderr.strip()}"
|
|
304
|
-
return True, f"deleted {full}"
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
# ---- Rehearsal step helpers (#720) -----------------------------------------
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def clone_repo_to_temp(
|
|
311
|
-
project_root: Path, target_dir: Path
|
|
312
|
-
) -> tuple[bool, str]:
|
|
313
|
-
"""Clone the local directive repo into ``target_dir`` (#720, #728).
|
|
314
|
-
|
|
315
|
-
Uses ``git clone <project_root> <target_dir>`` so the rehearsal does
|
|
316
|
-
not depend on network access during the clone step (the temp remote
|
|
317
|
-
is populated via the explicit-refspec push in ``push_mirror``
|
|
318
|
-
afterwards). The clone produces a normal working tree with a
|
|
319
|
-
populated ``refs/remotes/origin/*``; that is intentional -- the
|
|
320
|
-
rehearsal needs a working tree to run ``task release`` against, and
|
|
321
|
-
the remote-tracking refs do NOT leak to the GitHub temp repo because
|
|
322
|
-
``push_mirror`` uses explicit ``refs/heads/*`` + ``refs/tags/*``
|
|
323
|
-
refspecs (see ``push_mirror``'s docstring for the receive-pack
|
|
324
|
-
rationale).
|
|
325
|
-
|
|
326
|
-
Per #728 cycle 2 we also pin ``DEFT_PROJECT_ROOT=<target_dir>`` in
|
|
327
|
-
the subprocess env so an operator with that variable already
|
|
328
|
-
exported in their shell cannot accidentally cause helpers further
|
|
329
|
-
down the rehearsal pipeline to resolve back to the real directive
|
|
330
|
-
repo.
|
|
331
|
-
"""
|
|
332
|
-
env = os.environ.copy()
|
|
333
|
-
env["DEFT_PROJECT_ROOT"] = str(target_dir)
|
|
334
|
-
result = subprocess.run(
|
|
335
|
-
["git", "clone", str(project_root), str(target_dir)],
|
|
336
|
-
capture_output=True,
|
|
337
|
-
text=True,
|
|
338
|
-
timeout=300,
|
|
339
|
-
check=False,
|
|
340
|
-
env=env,
|
|
341
|
-
)
|
|
342
|
-
if result.returncode != 0:
|
|
343
|
-
return False, f"git clone failed: {result.stderr.strip()}"
|
|
344
|
-
return True, f"cloned {project_root} -> {target_dir}"
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def set_origin_to_temp_repo(
|
|
348
|
-
clone_dir: Path, owner: str, slug: str
|
|
349
|
-
) -> tuple[bool, str]:
|
|
350
|
-
"""Point the clone's origin at the auto-created temp repo (#720).
|
|
351
|
-
|
|
352
|
-
Uses the canonical https URL shape so the rehearsal works on hosts
|
|
353
|
-
that lack an SSH key registered with GitHub (which is the typical
|
|
354
|
-
Windows operator environment for this project).
|
|
355
|
-
"""
|
|
356
|
-
url = f"https://github.com/{owner}/{slug}.git"
|
|
357
|
-
result = release._run_git(clone_dir, "remote", "set-url", "origin", url)
|
|
358
|
-
if result.returncode != 0:
|
|
359
|
-
return False, (
|
|
360
|
-
f"git remote set-url failed: {result.stderr.strip()}"
|
|
361
|
-
)
|
|
362
|
-
return True, f"origin -> {url}"
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
def push_mirror(clone_dir: Path) -> tuple[bool, str]:
|
|
366
|
-
"""Populate the temp remote with branches and tags from the clone (#720).
|
|
367
|
-
|
|
368
|
-
Pushes every branch and every tag from the local clone to the
|
|
369
|
-
auto-created temp remote using two explicit refspecs:
|
|
370
|
-
``refs/heads/*:refs/heads/*`` and ``refs/tags/*:refs/tags/*``.
|
|
371
|
-
|
|
372
|
-
The function name retains the historical ``push_mirror`` label so
|
|
373
|
-
callers and tests stay stable, but the implementation deliberately
|
|
374
|
-
avoids ``git push --mirror``. ``--mirror`` is documented to push
|
|
375
|
-
every ref under ``refs/`` -- including ``refs/remotes/*``, the
|
|
376
|
-
local clone's remote-tracking refs. GitHub's receive-pack rejects
|
|
377
|
-
writes to that namespace, so a real ``--mirror`` push from a
|
|
378
|
-
non-bare clone (which is what we have here -- ``git clone
|
|
379
|
-
<project_root> <clone_dir>`` produces a normal working clone with
|
|
380
|
-
a populated ``refs/remotes/origin/*``) would fail every real
|
|
381
|
-
``task release:e2e`` run at this step. Explicit refspecs cover
|
|
382
|
-
the two namespaces the subsequent rehearsal cares about (branches
|
|
383
|
-
+ tags) without leaking remote-tracking refs.
|
|
384
|
-
"""
|
|
385
|
-
result = release._run_git(
|
|
386
|
-
clone_dir,
|
|
387
|
-
"push",
|
|
388
|
-
"origin",
|
|
389
|
-
"refs/heads/*:refs/heads/*",
|
|
390
|
-
"refs/tags/*:refs/tags/*",
|
|
391
|
-
)
|
|
392
|
-
if result.returncode != 0:
|
|
393
|
-
return False, f"git push (heads+tags refspecs) failed: {result.stderr.strip()}"
|
|
394
|
-
return True, "pushed heads + tags to temp origin"
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def _call_release_entrypoint(
|
|
398
|
-
entrypoint: Callable[[list[str] | None], int],
|
|
399
|
-
argv: list[str],
|
|
400
|
-
*,
|
|
401
|
-
clone_dir: Path,
|
|
402
|
-
timeout: float | None = None,
|
|
403
|
-
) -> tuple[int, str]:
|
|
404
|
-
"""Run a release entrypoint in-process with subprocess-style bounds.
|
|
405
|
-
|
|
406
|
-
The original task-backed dispatch capped release at 600s and rollback at
|
|
407
|
-
300s. In-process calls need the same fail-closed behavior so a hung release
|
|
408
|
-
command cannot block the e2e rehearsal forever.
|
|
409
|
-
"""
|
|
410
|
-
if timeout is None:
|
|
411
|
-
timeout = RELEASE_ENTRYPOINT_TIMEOUT_SECONDS
|
|
412
|
-
old_cwd = Path.cwd()
|
|
413
|
-
old_project_root = os.environ.get("DEFT_PROJECT_ROOT")
|
|
414
|
-
real_stdout, real_stderr = sys.stdout, sys.stderr
|
|
415
|
-
result: dict[str, tuple[int, str]] = {}
|
|
416
|
-
restore_owner = object()
|
|
417
|
-
timed_out = threading.Event()
|
|
418
|
-
|
|
419
|
-
def _activate_process_state() -> bool:
|
|
420
|
-
global _ENTRYPOINT_ACTIVE_RESTORE_OWNER # noqa: PLW0603
|
|
421
|
-
|
|
422
|
-
with _ENTRYPOINT_PROCESS_STATE_LOCK:
|
|
423
|
-
if timed_out.is_set():
|
|
424
|
-
return False
|
|
425
|
-
_ENTRYPOINT_ACTIVE_RESTORE_OWNER = restore_owner
|
|
426
|
-
os.environ["DEFT_PROJECT_ROOT"] = str(clone_dir)
|
|
427
|
-
os.chdir(clone_dir)
|
|
428
|
-
return True
|
|
429
|
-
|
|
430
|
-
def _restore_process_state() -> None:
|
|
431
|
-
global _ENTRYPOINT_ACTIVE_RESTORE_OWNER # noqa: PLW0603
|
|
432
|
-
|
|
433
|
-
with _ENTRYPOINT_PROCESS_STATE_LOCK:
|
|
434
|
-
if _ENTRYPOINT_ACTIVE_RESTORE_OWNER is not restore_owner:
|
|
435
|
-
return
|
|
436
|
-
_ENTRYPOINT_ACTIVE_RESTORE_OWNER = None
|
|
437
|
-
os.chdir(old_cwd)
|
|
438
|
-
if old_project_root is None:
|
|
439
|
-
os.environ.pop("DEFT_PROJECT_ROOT", None)
|
|
440
|
-
else:
|
|
441
|
-
os.environ["DEFT_PROJECT_ROOT"] = old_project_root
|
|
442
|
-
|
|
443
|
-
@contextlib.contextmanager
|
|
444
|
-
def _redirect_entrypoint_stdio(stdout: io.StringIO, stderr: io.StringIO):
|
|
445
|
-
previous_stdout: TextIO | None = None
|
|
446
|
-
previous_stderr: TextIO | None = None
|
|
447
|
-
active = False
|
|
448
|
-
with _ENTRYPOINT_PROCESS_STATE_LOCK:
|
|
449
|
-
if _ENTRYPOINT_ACTIVE_RESTORE_OWNER is restore_owner:
|
|
450
|
-
previous_stdout = sys.stdout
|
|
451
|
-
previous_stderr = sys.stderr
|
|
452
|
-
sys.stdout, sys.stderr = stdout, stderr
|
|
453
|
-
active = True
|
|
454
|
-
try:
|
|
455
|
-
yield active
|
|
456
|
-
finally:
|
|
457
|
-
if active:
|
|
458
|
-
with _ENTRYPOINT_PROCESS_STATE_LOCK:
|
|
459
|
-
if (
|
|
460
|
-
_ENTRYPOINT_ACTIVE_RESTORE_OWNER is restore_owner
|
|
461
|
-
and previous_stdout is not None
|
|
462
|
-
and previous_stderr is not None
|
|
463
|
-
):
|
|
464
|
-
sys.stdout, sys.stderr = previous_stdout, previous_stderr
|
|
465
|
-
|
|
466
|
-
def _timeout_process_state() -> None:
|
|
467
|
-
global _ENTRYPOINT_ACTIVE_RESTORE_OWNER # noqa: PLW0603
|
|
468
|
-
|
|
469
|
-
with _ENTRYPOINT_PROCESS_STATE_LOCK:
|
|
470
|
-
timed_out.set()
|
|
471
|
-
if _ENTRYPOINT_ACTIVE_RESTORE_OWNER is not restore_owner:
|
|
472
|
-
return
|
|
473
|
-
_ENTRYPOINT_ACTIVE_RESTORE_OWNER = None
|
|
474
|
-
# Own cleanup after timeout; the daemon's later finally block must
|
|
475
|
-
# not restore over any subsequent release entrypoint or capture.
|
|
476
|
-
sys.stdout, sys.stderr = real_stdout, real_stderr
|
|
477
|
-
os.chdir(old_cwd)
|
|
478
|
-
if old_project_root is None:
|
|
479
|
-
os.environ.pop("DEFT_PROJECT_ROOT", None)
|
|
480
|
-
else:
|
|
481
|
-
os.environ["DEFT_PROJECT_ROOT"] = old_project_root
|
|
482
|
-
|
|
483
|
-
def _worker() -> None:
|
|
484
|
-
stdout = io.StringIO()
|
|
485
|
-
stderr = io.StringIO()
|
|
486
|
-
try:
|
|
487
|
-
if not _activate_process_state():
|
|
488
|
-
return
|
|
489
|
-
with _redirect_entrypoint_stdio(stdout, stderr) as stdio_active:
|
|
490
|
-
if not stdio_active:
|
|
491
|
-
return
|
|
492
|
-
code = entrypoint(argv)
|
|
493
|
-
except SystemExit as exc:
|
|
494
|
-
raw = exc.code
|
|
495
|
-
code = raw if isinstance(raw, int) else (0 if raw is None else 1)
|
|
496
|
-
except Exception as exc: # noqa: BLE001 -- e2e diagnostics must record failures
|
|
497
|
-
message = f"{type(exc).__name__}: {exc}"
|
|
498
|
-
captured_stderr = stderr.getvalue()
|
|
499
|
-
stderr_value = f"{captured_stderr}\n{message}" if captured_stderr else message
|
|
500
|
-
result["value"] = (EXIT_VIOLATION, stderr_value or stdout.getvalue())
|
|
501
|
-
return
|
|
502
|
-
finally:
|
|
503
|
-
_restore_process_state()
|
|
504
|
-
output = stderr.getvalue() or stdout.getvalue()
|
|
505
|
-
result["value"] = (int(code or 0), output)
|
|
506
|
-
|
|
507
|
-
worker = threading.Thread(target=_worker, name="deft-release-entrypoint", daemon=True)
|
|
508
|
-
worker.start()
|
|
509
|
-
worker.join(timeout)
|
|
510
|
-
if worker.is_alive():
|
|
511
|
-
# A hung worker may still hold process-global stdout/stderr redirects.
|
|
512
|
-
# Claim cleanup before fail-closed; the daemon's later context exit is
|
|
513
|
-
# owner-aware and will not restore over a subsequent capture.
|
|
514
|
-
_timeout_process_state()
|
|
515
|
-
label = getattr(entrypoint, "__name__", "entrypoint")
|
|
516
|
-
return ENTRYPOINT_TIMEOUT_EXIT_CODE, f"{label} timed out after {timeout:g}s"
|
|
517
|
-
return result.get("value", (EXIT_VIOLATION, "entrypoint produced no result"))
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
def dispatch_task_release(
|
|
521
|
-
clone_dir: Path, version: str, repo: str
|
|
522
|
-
) -> tuple[bool, str]:
|
|
523
|
-
"""Invoke release.py inside the clone with skip flags and the
|
|
524
|
-
vBRIEF-drift override (#720, #728, post-#754 harness fix).
|
|
525
|
-
|
|
526
|
-
The full dispatched argv is
|
|
527
|
-
``release.py <version> --repo <repo> --skip-ci --skip-build --allow-vbrief-drift``.
|
|
528
|
-
|
|
529
|
-
Skipping CI + build keeps the rehearsal wall-clock manageable; both
|
|
530
|
-
are covered by the unit-test suite. The 10-step pipeline still
|
|
531
|
-
exercises CHANGELOG promotion, ROADMAP refresh, commit, tag, atomic
|
|
532
|
-
push, and ``gh release create --draft``.
|
|
533
|
-
|
|
534
|
-
#728 cycle 2: ``env["DEFT_PROJECT_ROOT"] = str(clone_dir)`` is
|
|
535
|
-
explicitly pinned BEFORE invoking ``task release``. The release CLI
|
|
536
|
-
resolves its repository root via ``DEFT_PROJECT_ROOT`` (when set) ->
|
|
537
|
-
``--project-root`` -> the script's own parent. If the operator's
|
|
538
|
-
shell already exported ``DEFT_PROJECT_ROOT`` (a common pattern
|
|
539
|
-
when running deft itself out of a worktree), the rehearsal
|
|
540
|
-
subprocess would resolve back to the REAL directive repo and the
|
|
541
|
-
rest of the pipeline (CHANGELOG promotion, commit, tag, ``git push
|
|
542
|
-
--atomic origin master v0.0.1``) would mutate ``deftai/directive``
|
|
543
|
-
instead of the temp clone. Pinning the env var to ``clone_dir``
|
|
544
|
-
eliminates that ambient-state hazard regardless of the operator's
|
|
545
|
-
shell setup.
|
|
546
|
-
|
|
547
|
-
Post-#754 harness fix: ``--allow-vbrief-drift`` is passed because
|
|
548
|
-
the temp rehearsal repo is auto-created empty (zero issues) and
|
|
549
|
-
the inverted-lookup vBRIEF-lifecycle-sync gate (#754) classifies
|
|
550
|
-
every referenced issue number as NOT_FOUND -> Section (c) mismatch
|
|
551
|
-
against an empty target. The gate has no meaningful signal in the
|
|
552
|
-
rehearsal context, so the explicit-acknowledgment escape hatch is
|
|
553
|
-
the correct surface to bypass it. The production cut path (against
|
|
554
|
-
the real repo with real issues) does NOT pass this flag and remains
|
|
555
|
-
fully gated. Without this flag, every ``task release:e2e`` invocation
|
|
556
|
-
since #734 landed has failed at the inner Step 3 lifecycle gate.
|
|
557
|
-
"""
|
|
558
|
-
argv = [
|
|
559
|
-
version,
|
|
560
|
-
"--repo", repo,
|
|
561
|
-
"--skip-ci",
|
|
562
|
-
"--skip-build",
|
|
563
|
-
"--allow-vbrief-drift",
|
|
564
|
-
]
|
|
565
|
-
code, output = _call_release_entrypoint(
|
|
566
|
-
release.main,
|
|
567
|
-
argv,
|
|
568
|
-
clone_dir=clone_dir,
|
|
569
|
-
timeout=RELEASE_ENTRYPOINT_TIMEOUT_SECONDS,
|
|
570
|
-
)
|
|
571
|
-
if code != 0:
|
|
572
|
-
return False, (
|
|
573
|
-
f"release.py failed (exit {code}): "
|
|
574
|
-
f"{output.strip()}"
|
|
575
|
-
)
|
|
576
|
-
return True, f"release.py {version} --repo {repo} (draft) ran clean"
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def verify_draft_release(
|
|
580
|
-
owner: str, slug: str, version: str
|
|
581
|
-
) -> tuple[bool, str]:
|
|
582
|
-
"""Assert ``gh release view`` reports the draft for ``v<version>`` (#720).
|
|
583
|
-
|
|
584
|
-
Verifies (a) the release exists, (b) ``isDraft == true``, (c)
|
|
585
|
-
``tagName == v<version>``. Anything else returns False so the
|
|
586
|
-
rehearsal fails loudly.
|
|
587
|
-
"""
|
|
588
|
-
gh_path = release._resolve_gh()
|
|
589
|
-
if gh_path is None:
|
|
590
|
-
return False, "gh CLI not found on PATH"
|
|
591
|
-
tag = f"v{version}"
|
|
592
|
-
full = f"{owner}/{slug}"
|
|
593
|
-
cmd = [
|
|
594
|
-
gh_path, "release", "view", tag,
|
|
595
|
-
"--repo", full,
|
|
596
|
-
"--json", "isDraft,tagName,name,url",
|
|
597
|
-
]
|
|
598
|
-
try:
|
|
599
|
-
result = subprocess.run(
|
|
600
|
-
cmd,
|
|
601
|
-
capture_output=True,
|
|
602
|
-
text=True,
|
|
603
|
-
timeout=60,
|
|
604
|
-
check=False,
|
|
605
|
-
env=os.environ.copy(),
|
|
606
|
-
)
|
|
607
|
-
except FileNotFoundError:
|
|
608
|
-
return False, "gh CLI not found on PATH"
|
|
609
|
-
if result.returncode != 0:
|
|
610
|
-
return False, f"gh release view failed: {result.stderr.strip()}"
|
|
611
|
-
try:
|
|
612
|
-
payload = json.loads(result.stdout)
|
|
613
|
-
except json.JSONDecodeError as exc:
|
|
614
|
-
return False, f"gh release view returned non-JSON: {exc}"
|
|
615
|
-
if not payload.get("isDraft"):
|
|
616
|
-
return False, (
|
|
617
|
-
f"draft verify FAIL: expected isDraft=true on {full} {tag}, "
|
|
618
|
-
f"got {payload!r}"
|
|
619
|
-
)
|
|
620
|
-
if payload.get("tagName") != tag:
|
|
621
|
-
return False, (
|
|
622
|
-
f"draft verify FAIL: expected tagName={tag!r} on {full}, "
|
|
623
|
-
f"got tagName={payload.get('tagName')!r}"
|
|
624
|
-
)
|
|
625
|
-
return True, f"verified draft {tag} on {full}"
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
def verify_tag(clone_dir: Path, version: str) -> tuple[bool, str]:
|
|
629
|
-
"""Assert the tag ``v<version>`` exists on the temp remote (#720).
|
|
630
|
-
|
|
631
|
-
Uses ``git ls-remote --tags origin`` so the assertion is independent
|
|
632
|
-
of the local tag database (the local clone may have already pushed +
|
|
633
|
-
cleaned up; what matters is the remote ref).
|
|
634
|
-
"""
|
|
635
|
-
tag = f"v{version}"
|
|
636
|
-
result = release._run_git(
|
|
637
|
-
clone_dir, "ls-remote", "--tags", "origin", f"refs/tags/{tag}"
|
|
638
|
-
)
|
|
639
|
-
if result.returncode != 0:
|
|
640
|
-
return False, f"git ls-remote failed: {result.stderr.strip()}"
|
|
641
|
-
if not result.stdout.strip():
|
|
642
|
-
return False, f"tag verify FAIL: {tag} not present on temp origin"
|
|
643
|
-
return True, f"verified tag {tag} present on temp origin"
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
# ---- npm publish dry-run rehearsal (#1910) ---------------------------------
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
def _resolve_pnpm() -> list[str] | None:
|
|
650
|
-
"""Resolve a pnpm invocation prefix for the clone build (#1910).
|
|
651
|
-
|
|
652
|
-
Prefers a ``pnpm`` binary on PATH; falls back to ``corepack pnpm`` so a
|
|
653
|
-
Node install that ships corepack (the npm-publish.yml path) works without
|
|
654
|
-
a globally-installed pnpm. Returns ``None`` when neither is available.
|
|
655
|
-
"""
|
|
656
|
-
pnpm = shutil.which("pnpm")
|
|
657
|
-
if pnpm:
|
|
658
|
-
return [pnpm]
|
|
659
|
-
corepack = shutil.which("corepack")
|
|
660
|
-
if corepack:
|
|
661
|
-
return [corepack, "pnpm"]
|
|
662
|
-
return None
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
def _run_npm_step(
|
|
666
|
-
cmd: list[str], cwd: Path, env: dict[str, str], label: str, timeout: int
|
|
667
|
-
) -> tuple[bool, str]:
|
|
668
|
-
"""Run one npm/pnpm subprocess step and normalise it to ``(ok, reason)``.
|
|
669
|
-
|
|
670
|
-
Uses ``encoding="utf-8", errors="replace"`` per the #1366 safe-capture
|
|
671
|
-
rule so an undecodable byte in npm/pnpm output cannot crash the reader.
|
|
672
|
-
"""
|
|
673
|
-
try:
|
|
674
|
-
result = subprocess.run(
|
|
675
|
-
cmd,
|
|
676
|
-
cwd=str(cwd),
|
|
677
|
-
capture_output=True,
|
|
678
|
-
text=True,
|
|
679
|
-
encoding="utf-8",
|
|
680
|
-
errors="replace",
|
|
681
|
-
timeout=timeout,
|
|
682
|
-
check=False,
|
|
683
|
-
env=env,
|
|
684
|
-
)
|
|
685
|
-
except FileNotFoundError:
|
|
686
|
-
return False, f"{label}: command not found ({cmd[0]})"
|
|
687
|
-
except subprocess.TimeoutExpired:
|
|
688
|
-
return False, f"{label}: timed out after {timeout}s"
|
|
689
|
-
if result.returncode != 0:
|
|
690
|
-
detail = (result.stderr or result.stdout or "").strip()
|
|
691
|
-
return False, f"{label} failed (exit {result.returncode}): {detail[-500:]}"
|
|
692
|
-
return True, f"{label} OK"
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
def align_npm_package_versions(clone_dir: Path, version: str) -> tuple[bool, str]:
|
|
696
|
-
"""Bump the four published package versions + resolve workspace deps (#1910).
|
|
697
|
-
|
|
698
|
-
Mirrors the npm-publish.yml "Align package versions with release tag" and
|
|
699
|
-
"Resolve workspace protocol for npm publish" steps: every published
|
|
700
|
-
``package.json`` is set to ``<version>`` and any ``workspace:`` dependency
|
|
701
|
-
spec is rewritten to ``^<version>`` (npm cannot publish the pnpm
|
|
702
|
-
``workspace:`` protocol verbatim).
|
|
703
|
-
|
|
704
|
-
Folds in the scope item-4 version-alignment assertion: after writing,
|
|
705
|
-
each manifest is read back and must report exactly ``<version>`` so a
|
|
706
|
-
drift / malformed-manifest bug surfaces in the rehearsal rather than at
|
|
707
|
-
real tag time.
|
|
708
|
-
"""
|
|
709
|
-
for pkg in NPM_PUBLISH_PACKAGES:
|
|
710
|
-
manifest = clone_dir / "packages" / pkg / "package.json"
|
|
711
|
-
try:
|
|
712
|
-
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
713
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
714
|
-
return False, f"version-align FAIL: cannot read packages/{pkg}/package.json: {exc}"
|
|
715
|
-
data["version"] = version
|
|
716
|
-
deps = data.get("dependencies")
|
|
717
|
-
if isinstance(deps, dict):
|
|
718
|
-
for name, spec in list(deps.items()):
|
|
719
|
-
if isinstance(spec, str) and spec.startswith("workspace:"):
|
|
720
|
-
deps[name] = f"^{version}"
|
|
721
|
-
try:
|
|
722
|
-
manifest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
723
|
-
readback = json.loads(manifest.read_text(encoding="utf-8"))
|
|
724
|
-
except (OSError, json.JSONDecodeError) as exc:
|
|
725
|
-
return False, f"version-align FAIL: cannot write packages/{pkg}/package.json: {exc}"
|
|
726
|
-
if readback.get("version") != version:
|
|
727
|
-
return False, (
|
|
728
|
-
f"version-align FAIL: packages/{pkg} version="
|
|
729
|
-
f"{readback.get('version')!r} != {version!r}"
|
|
730
|
-
)
|
|
731
|
-
return True, f"aligned 4 package versions to {version} (+ resolved workspace protocol)"
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
def rehearse_npm_publish(clone_dir: Path, version: str) -> tuple[bool, str]:
|
|
735
|
-
"""Dry-run the npm publish for the four @deftai/directive* packages (#1910).
|
|
736
|
-
|
|
737
|
-
Mirrors ``.github/workflows/npm-publish.yml`` so a broken ``files``
|
|
738
|
-
allowlist, version drift, or dependency-order bug surfaces in
|
|
739
|
-
``task release:e2e`` BEFORE a real ``v*`` tag fires the publish
|
|
740
|
-
workflow -- without ever touching the real registry (every package is
|
|
741
|
-
published with ``--dry-run``).
|
|
742
|
-
|
|
743
|
-
Steps, all inside the throwaway clone:
|
|
744
|
-
|
|
745
|
-
1. Resolve ``npm``. SOFT-SKIP (returns ``ok=True``) when npm is absent so
|
|
746
|
-
Node-less operators are not blocked -- symmetric to ``--skip-npm``.
|
|
747
|
-
2. Resolve pnpm (or ``corepack pnpm``) and ``pnpm install
|
|
748
|
-
--frozen-lockfile``; the fresh ``git clone`` has no ``node_modules``.
|
|
749
|
-
3. ``pnpm -w run build``; ``dist/`` must exist for the dist-only ``files``
|
|
750
|
-
allowlist to produce a meaningful tarball.
|
|
751
|
-
4. Align the four ``package.json`` versions to ``<version>`` and resolve
|
|
752
|
-
the ``workspace:`` protocol (folds in the item-4 version assertion).
|
|
753
|
-
5. ``npm publish --dry-run --access public --tag e2e-rehearsal`` per
|
|
754
|
-
package in dependency order (types -> core -> content -> cli). The
|
|
755
|
-
throwaway dist-tag bypasses npm's implicit-``latest`` check when the
|
|
756
|
-
rehearsal sentinel version is below the highest published version
|
|
757
|
-
(#1925).
|
|
758
|
-
|
|
759
|
-
Returns ``(ok, reason)`` like ``verify_draft_release`` / ``verify_tag`` so
|
|
760
|
-
the orchestrator and tests treat it uniformly. Because installing +
|
|
761
|
-
building the workspace blows the <90s rehearsal budget, the orchestrator
|
|
762
|
-
only runs this step when ``--skip-npm`` is NOT set.
|
|
763
|
-
"""
|
|
764
|
-
npm_path = shutil.which("npm")
|
|
765
|
-
if npm_path is None:
|
|
766
|
-
return True, "SKIP (npm not on PATH; Node-less operator)"
|
|
767
|
-
pnpm_cmd = _resolve_pnpm()
|
|
768
|
-
if pnpm_cmd is None:
|
|
769
|
-
return False, (
|
|
770
|
-
"npm present but neither pnpm nor corepack is on PATH -- "
|
|
771
|
-
"cannot build the workspace for the dry-run"
|
|
772
|
-
)
|
|
773
|
-
|
|
774
|
-
env = os.environ.copy()
|
|
775
|
-
env["DEFT_PROJECT_ROOT"] = str(clone_dir)
|
|
776
|
-
|
|
777
|
-
ok, reason = _run_npm_step(
|
|
778
|
-
[*pnpm_cmd, "install", "--frozen-lockfile"],
|
|
779
|
-
clone_dir, env, "pnpm install", NPM_INSTALL_TIMEOUT_SECONDS,
|
|
780
|
-
)
|
|
781
|
-
if not ok:
|
|
782
|
-
return False, reason
|
|
783
|
-
ok, reason = _run_npm_step(
|
|
784
|
-
[*pnpm_cmd, "-w", "run", "build"],
|
|
785
|
-
clone_dir, env, "pnpm build", NPM_BUILD_TIMEOUT_SECONDS,
|
|
786
|
-
)
|
|
787
|
-
if not ok:
|
|
788
|
-
return False, reason
|
|
789
|
-
ok, reason = align_npm_package_versions(clone_dir, version)
|
|
790
|
-
if not ok:
|
|
791
|
-
return False, reason
|
|
792
|
-
for pkg in NPM_PUBLISH_PACKAGES:
|
|
793
|
-
pkg_dir = clone_dir / "packages" / pkg
|
|
794
|
-
ok, reason = _run_npm_step(
|
|
795
|
-
[
|
|
796
|
-
npm_path,
|
|
797
|
-
"publish",
|
|
798
|
-
"--dry-run",
|
|
799
|
-
"--access",
|
|
800
|
-
"public",
|
|
801
|
-
"--tag",
|
|
802
|
-
NPM_E2E_REHEARSAL_TAG,
|
|
803
|
-
],
|
|
804
|
-
pkg_dir,
|
|
805
|
-
env,
|
|
806
|
-
f"npm publish --dry-run --tag {NPM_E2E_REHEARSAL_TAG} packages/{pkg}",
|
|
807
|
-
NPM_PUBLISH_DRYRUN_TIMEOUT_SECONDS,
|
|
808
|
-
)
|
|
809
|
-
if not ok:
|
|
810
|
-
return False, reason
|
|
811
|
-
return True, (
|
|
812
|
-
"npm publish --dry-run clean for 4 packages "
|
|
813
|
-
f"(types -> core -> content -> cli) at v{version}"
|
|
814
|
-
)
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
def dispatch_task_release_rollback(
|
|
818
|
-
clone_dir: Path, version: str, repo: str
|
|
819
|
-
) -> tuple[bool, str]:
|
|
820
|
-
"""Invoke release_rollback.py ``<version> --repo <repo>`` (#720, #728).
|
|
821
|
-
|
|
822
|
-
Exercises the rollback path against the temp repo so a regression in
|
|
823
|
-
the state-aware unwind (states 1-3) surfaces in the e2e job rather
|
|
824
|
-
than during a real production rollback.
|
|
825
|
-
|
|
826
|
-
#728 cycle 2: same ``DEFT_PROJECT_ROOT`` pinning rationale as
|
|
827
|
-
``dispatch_task_release`` -- without the override, an operator with
|
|
828
|
-
``DEFT_PROJECT_ROOT`` exported in their shell would have the
|
|
829
|
-
rollback subprocess resolve to the real directive repo, producing
|
|
830
|
-
either a false VIOLATION (release-prep SHA cannot be resolved) or
|
|
831
|
-
-- worse -- mutating the real repo's history.
|
|
832
|
-
"""
|
|
833
|
-
argv = [version, "--repo", repo]
|
|
834
|
-
code, output = _call_release_entrypoint(
|
|
835
|
-
release_rollback.main,
|
|
836
|
-
argv,
|
|
837
|
-
clone_dir=clone_dir,
|
|
838
|
-
timeout=ROLLBACK_ENTRYPOINT_TIMEOUT_SECONDS,
|
|
839
|
-
)
|
|
840
|
-
if code != 0:
|
|
841
|
-
return False, (
|
|
842
|
-
f"release_rollback.py failed (exit {code}): "
|
|
843
|
-
f"{output.strip()}"
|
|
844
|
-
)
|
|
845
|
-
return True, f"release_rollback.py {version} --repo {repo} ran clean"
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
def run_rehearsal(
|
|
849
|
-
owner: str, slug: str, project_root: Path,
|
|
850
|
-
version: str = REHEARSAL_VERSION,
|
|
851
|
-
*,
|
|
852
|
-
skip_npm: bool = False,
|
|
853
|
-
) -> tuple[bool, str]:
|
|
854
|
-
"""Execute the full pipeline-mirror rehearsal (#720, #1910).
|
|
855
|
-
|
|
856
|
-
Orchestrates the rehearsal steps inside a
|
|
857
|
-
``tempfile.TemporaryDirectory``: clone -> set-origin -> push-mirror ->
|
|
858
|
-
task release -> verify draft -> verify tag -> (npm publish dry-run) ->
|
|
859
|
-
task release:rollback. On the first step failure, short-circuits and
|
|
860
|
-
returns the diagnostic; the caller is responsible for cleanup of the
|
|
861
|
-
temp GitHub repo (run_e2e wraps this in ``try/finally``).
|
|
862
|
-
|
|
863
|
-
Per #1910 the npm publish dry-run step is inserted after ``verify tag``
|
|
864
|
-
and before ``task release:rollback`` unless ``skip_npm`` is set; it
|
|
865
|
-
soft-skips internally when ``npm`` is absent from PATH so Node-less
|
|
866
|
-
operators are not blocked.
|
|
867
|
-
|
|
868
|
-
Pre-#720 this function was a smoke-test ``gh repo view`` (existence
|
|
869
|
-
check only). The deeper flow surfaces real regressions in the
|
|
870
|
-
release pipeline before they hit master.
|
|
871
|
-
"""
|
|
872
|
-
repo_full = f"{owner}/{slug}"
|
|
873
|
-
with tempfile.TemporaryDirectory(prefix="deft-e2e-") as tmpdir:
|
|
874
|
-
clone_dir = Path(tmpdir) / "clone"
|
|
875
|
-
steps: list[tuple[str, Callable[[], tuple[bool, str]]]] = [
|
|
876
|
-
("clone", lambda: clone_repo_to_temp(project_root, clone_dir)),
|
|
877
|
-
("set-origin", lambda: set_origin_to_temp_repo(clone_dir, owner, slug)),
|
|
878
|
-
("push-mirror", lambda: push_mirror(clone_dir)),
|
|
879
|
-
("task release", lambda: dispatch_task_release(clone_dir, version, repo_full)),
|
|
880
|
-
("verify draft", lambda: verify_draft_release(owner, slug, version)),
|
|
881
|
-
("verify tag", lambda: verify_tag(clone_dir, version)),
|
|
882
|
-
]
|
|
883
|
-
if not skip_npm:
|
|
884
|
-
steps.append(
|
|
885
|
-
("npm publish dry-run", lambda: rehearse_npm_publish(clone_dir, version))
|
|
886
|
-
)
|
|
887
|
-
steps.append(
|
|
888
|
-
(
|
|
889
|
-
"task release:rollback",
|
|
890
|
-
lambda: dispatch_task_release_rollback(clone_dir, version, repo_full),
|
|
891
|
-
)
|
|
892
|
-
)
|
|
893
|
-
for label, step in steps:
|
|
894
|
-
ok, reason = step()
|
|
895
|
-
_emit(f" rehearsal step: {label}", f"{'OK' if ok else 'FAIL'} ({reason})")
|
|
896
|
-
if not ok:
|
|
897
|
-
return False, f"{label}: {reason}"
|
|
898
|
-
npm_note = " (npm dry-run skipped)" if skip_npm else " -> npm publish dry-run"
|
|
899
|
-
return True, (
|
|
900
|
-
f"pipeline-mirror rehearsal succeeded against {repo_full} "
|
|
901
|
-
f"({len(steps)} steps; clone -> push heads+tags -> task release -> "
|
|
902
|
-
f"verify draft+tag{npm_note} -> rollback)"
|
|
903
|
-
)
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
# ---- pipeline ---------------------------------------------------------------
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
def run_e2e(config: E2EConfig) -> int:
|
|
910
|
-
"""Execute the e2e rehearsal pipeline; returns the process exit code.
|
|
911
|
-
|
|
912
|
-
The function is intentionally structured as ``provision -> rehearse
|
|
913
|
-
-> destroy`` with the cleanup in a ``finally`` block so a failed
|
|
914
|
-
rehearsal still triggers ``gh repo delete``. If the cleanup itself
|
|
915
|
-
fails, a warning is printed but the rehearsal's own exit code wins
|
|
916
|
-
so the operator does not see "rehearsal failed" reported as
|
|
917
|
-
"cleanup failed".
|
|
918
|
-
"""
|
|
919
|
-
slug = config.repo_slug or generate_repo_slug()
|
|
920
|
-
owner = config.owner
|
|
921
|
-
|
|
922
|
-
if config.dry_run:
|
|
923
|
-
_emit(
|
|
924
|
-
"Provision temp repo",
|
|
925
|
-
f"DRYRUN (would run `gh repo create --private {owner}/{slug}`)",
|
|
926
|
-
)
|
|
927
|
-
npm_plan = (
|
|
928
|
-
"task release:rollback"
|
|
929
|
-
if config.skip_npm
|
|
930
|
-
else "npm publish dry-run (4 packages) -> task release:rollback"
|
|
931
|
-
)
|
|
932
|
-
_emit(
|
|
933
|
-
"Rehearsal",
|
|
934
|
-
(
|
|
935
|
-
"DRYRUN (would run pipeline-mirror rehearsal: clone -> "
|
|
936
|
-
"push heads+tags -> task release -> verify draft + tag -> "
|
|
937
|
-
f"{npm_plan} against temp repo)"
|
|
938
|
-
),
|
|
939
|
-
)
|
|
940
|
-
_emit(
|
|
941
|
-
"Destroy temp repo",
|
|
942
|
-
f"DRYRUN (would run `gh repo delete {owner}/{slug} --yes`)",
|
|
943
|
-
)
|
|
944
|
-
return EXIT_OK
|
|
945
|
-
|
|
946
|
-
# Provision.
|
|
947
|
-
ok, reason = provision_temp_repo(owner, slug)
|
|
948
|
-
if not ok:
|
|
949
|
-
_emit(f"Provision {owner}/{slug}", f"FAIL ({reason})")
|
|
950
|
-
return EXIT_VIOLATION
|
|
951
|
-
_emit(f"Provision {owner}/{slug}", f"OK ({reason})")
|
|
952
|
-
|
|
953
|
-
rehearsal_rc = EXIT_OK
|
|
954
|
-
try:
|
|
955
|
-
ok, reason = run_rehearsal(
|
|
956
|
-
owner, slug, config.project_root, skip_npm=config.skip_npm
|
|
957
|
-
)
|
|
958
|
-
if ok:
|
|
959
|
-
_emit("Rehearsal", f"OK ({reason})")
|
|
960
|
-
else:
|
|
961
|
-
_emit("Rehearsal", f"FAIL ({reason})")
|
|
962
|
-
rehearsal_rc = EXIT_VIOLATION
|
|
963
|
-
finally:
|
|
964
|
-
if config.keep_repo:
|
|
965
|
-
_emit(
|
|
966
|
-
f"Destroy {owner}/{slug}",
|
|
967
|
-
"SKIP (--keep-repo set; manual cleanup required: "
|
|
968
|
-
f"gh repo delete {owner}/{slug} --yes)",
|
|
969
|
-
)
|
|
970
|
-
else:
|
|
971
|
-
ok, reason = destroy_temp_repo(owner, slug)
|
|
972
|
-
if ok:
|
|
973
|
-
_emit(f"Destroy {owner}/{slug}", f"OK ({reason})")
|
|
974
|
-
else:
|
|
975
|
-
# Cleanup failure does NOT override the rehearsal exit
|
|
976
|
-
# code; we surface a warning + manual cleanup hint and
|
|
977
|
-
# let the rehearsal's status stand.
|
|
978
|
-
_emit(
|
|
979
|
-
f"Destroy {owner}/{slug}",
|
|
980
|
-
f"WARN ({reason}); manual cleanup hint: "
|
|
981
|
-
f"gh repo delete {owner}/{slug} --yes",
|
|
982
|
-
)
|
|
983
|
-
|
|
984
|
-
return rehearsal_rc
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
# ---- main -------------------------------------------------------------------
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
def main(argv: list[str] | None = None) -> int:
|
|
991
|
-
parser = _build_parser()
|
|
992
|
-
args = parser.parse_args(argv)
|
|
993
|
-
|
|
994
|
-
if not args.owner:
|
|
995
|
-
print("Error: --owner must be a non-empty string.", file=sys.stderr)
|
|
996
|
-
return EXIT_CONFIG_ERROR
|
|
997
|
-
|
|
998
|
-
project_root = release._resolve_project_root(args.project_root)
|
|
999
|
-
|
|
1000
|
-
config = E2EConfig(
|
|
1001
|
-
owner=args.owner,
|
|
1002
|
-
project_root=project_root,
|
|
1003
|
-
dry_run=args.dry_run,
|
|
1004
|
-
keep_repo=args.keep_repo,
|
|
1005
|
-
skip_npm=args.skip_npm,
|
|
1006
|
-
)
|
|
1007
|
-
return run_e2e(config)
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
if __name__ == "__main__":
|
|
1011
|
-
sys.exit(main())
|