@topy-ai/maggie 0.6.7 → 0.6.8
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/README.md
CHANGED
|
@@ -179,8 +179,8 @@ artifact schemas.
|
|
|
179
179
|
Recommended upgrade sequence for the current release:
|
|
180
180
|
|
|
181
181
|
```bash
|
|
182
|
-
npx @topy-ai/maggie@0.6.
|
|
183
|
-
npx @topy-ai/maggie@0.6.
|
|
182
|
+
npx @topy-ai/maggie@0.6.8 update --project . --force
|
|
183
|
+
npx @topy-ai/maggie@0.6.8 cleanup --project .
|
|
184
184
|
```
|
|
185
185
|
|
|
186
186
|
## MaggieDash lifecycle
|
package/README.zh-TW.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: maggie-deployment
|
|
3
3
|
description: Deploy and operate Maggie blog projects with Cloudflare Workers as the default target, while preserving an adapter boundary for VPS, GCP, and AWS.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 1.
|
|
5
|
+
version: 1.2.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Maggie Deployment
|
|
@@ -70,6 +70,20 @@ The output contains only the immutable release/current layout, systemd unit,
|
|
|
70
70
|
Nginx reverse-proxy config, health commands and rollback command. It never
|
|
71
71
|
contains credentials and never SSHs, changes DNS, restarts systemd or deploys.
|
|
72
72
|
|
|
73
|
+
VPS plans include a bounded retention policy: keep two immutable releases,
|
|
74
|
+
preserve the `current` target and rollback target, and review prune candidates
|
|
75
|
+
before any operator executes cleanup. Generate a read-only candidate report:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 tools/clis/maggie_deployment.py --retention-plan \
|
|
79
|
+
--release-root /var/www/example \
|
|
80
|
+
--current-link /var/www/example/current \
|
|
81
|
+
--keep-releases 2 --output .maggie/deployment/retention-plan.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The CLI never deletes release directories and never permits fewer than two
|
|
85
|
+
retained releases.
|
|
86
|
+
|
|
73
87
|
Validate the database release separately before a VPS migration:
|
|
74
88
|
|
|
75
89
|
```bash
|
|
@@ -81,6 +95,14 @@ The manifest must reference `DATABASE_URL` (not a literal URL), declare an
|
|
|
81
95
|
advancing version, forward-only/idempotent policy, backup and restore commands,
|
|
82
96
|
and a tested restore artifact. The validator never runs those commands.
|
|
83
97
|
|
|
98
|
+
If a release depends on existing rows or seeded data, set
|
|
99
|
+
`dataDependencies: true` in `.maggie/migration-manifest.json` and provide
|
|
100
|
+
`.maggie/deployment/data-release.json` before deployment. The checkpoint must
|
|
101
|
+
use `maggie-data-release.v1`, declare `requiredTables`, compare
|
|
102
|
+
`previousCounts` and `currentCounts`, and include the release, check time, and
|
|
103
|
+
`passed: true`. Deployment preflight fails closed when this evidence is absent
|
|
104
|
+
or incomplete; it does not copy or mutate data.
|
|
105
|
+
|
|
84
106
|
Validate and materialise scheduled jobs only after their quota and lock policy
|
|
85
107
|
has been reviewed:
|
|
86
108
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
|
|
@@ -57,10 +58,21 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
57
58
|
rollback_passed = bool(evidence.get("passed") is True and evidence.get("environment") == "production" and evidence.get("previousRelease") and evidence.get("testedAt"))
|
|
58
59
|
except (OSError, json.JSONDecodeError):
|
|
59
60
|
rollback_passed = False
|
|
60
|
-
|
|
61
|
+
migration_manifest = project / ".maggie" / "migration-manifest.json"
|
|
62
|
+
migration = {}
|
|
63
|
+
if migration_manifest.exists():
|
|
64
|
+
try:
|
|
65
|
+
migration = json.loads(migration_manifest.read_text(encoding="utf-8"))
|
|
66
|
+
except (OSError, json.JSONDecodeError):
|
|
67
|
+
migration = {}
|
|
68
|
+
data_path = project / ".maggie" / "deployment" / "data-release.json"
|
|
69
|
+
data_required = bool(migration.get("dataDependencies"))
|
|
70
|
+
data_checkpoint = validate_data_checkpoint(data_path) if data_path.exists() else {"passed": not data_required, "declared": False, "errors": ["data-release.json is required for a data-dependent release"] if data_required else []}
|
|
71
|
+
retention_ok = bool(plan.get("retention", {}).get("keep") in (2, 3, 4, 5) and plan.get("retention", {}).get("prune") and plan.get("retention", {}).get("preserve"))
|
|
72
|
+
result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": True, "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
|
|
61
73
|
result["vps"] = {"runtime":"Node.js standalone","reverseProxy":"Nginx","processManager":"systemd","releaseLayout":"/var/www/<site>/releases/<release> + current symlink","dns":"Cloudflare DNS or registrar DNS; verify apex and www separately","remoteCommands":["npm ci","npm run build","systemctl restart <service>","systemctl is-active <service>","curl -fsS https://<domain>/robots.txt","curl -fsS https://<domain>/sitemap.xml"],"handoverFields":["host","sshUser","domain","service","release","previousRelease","nginxConfig","migrationVersion","verification","rollback"]}
|
|
62
74
|
result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
|
|
63
|
-
result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
75
|
+
result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
64
76
|
else:
|
|
65
77
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
|
|
66
78
|
return result
|
|
@@ -85,6 +97,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
|
|
|
85
97
|
"current_release": current,
|
|
86
98
|
"node_port": node_port,
|
|
87
99
|
"release_layout": f"{root}/releases/<immutable-release> plus current symlink",
|
|
100
|
+
"retention": {"keep": 2, "preserve": [current, f"{root}/releases/<previous-release>"], "prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf --"},
|
|
88
101
|
"staging_first": True,
|
|
89
102
|
"secrets": "external environment file; no secrets in generated artifacts",
|
|
90
103
|
"commands": {
|
|
@@ -101,6 +114,38 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int) -> di
|
|
|
101
114
|
}
|
|
102
115
|
|
|
103
116
|
|
|
117
|
+
def validate_data_checkpoint(path: Path) -> dict:
|
|
118
|
+
try:
|
|
119
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
120
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
121
|
+
return {"passed": False, "declared": True, "errors": [f"data checkpoint unreadable: {exc}"]}
|
|
122
|
+
required = value.get("requiredTables")
|
|
123
|
+
previous = value.get("previousCounts")
|
|
124
|
+
current = value.get("currentCounts")
|
|
125
|
+
errors = []
|
|
126
|
+
if value.get("schemaVersion") != "maggie-data-release.v1": errors.append("data checkpoint schemaVersion is unsupported")
|
|
127
|
+
if value.get("passed") is not True: errors.append("data checkpoint is not passed")
|
|
128
|
+
if not isinstance(required, list) or not required: errors.append("requiredTables must be a non-empty list")
|
|
129
|
+
if not isinstance(previous, dict) or not isinstance(current, dict): errors.append("previousCounts and currentCounts are required")
|
|
130
|
+
if isinstance(required, list) and isinstance(current, dict):
|
|
131
|
+
missing = [table for table in required if table not in current]
|
|
132
|
+
if missing: errors.append("currentCounts is missing required tables")
|
|
133
|
+
if not value.get("release") or not value.get("checkedAt"): errors.append("release and checkedAt are required")
|
|
134
|
+
return {"passed": not errors, "declared": True, "errors": errors}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def retention_plan(release_root: str, current_link: str, keep: int) -> dict:
|
|
138
|
+
if keep < 2 or keep > 5:
|
|
139
|
+
raise ValueError("keep must be between 2 and 5")
|
|
140
|
+
root = Path(release_root).resolve()
|
|
141
|
+
releases = sorted((path for path in root.iterdir() if path.is_dir() and not path.is_symlink()), key=lambda path: path.stat().st_mtime, reverse=True) if root.is_dir() else []
|
|
142
|
+
current_target = Path(current_link).resolve() if Path(current_link).exists() else None
|
|
143
|
+
preserved = {str(current_target)} if current_target else set()
|
|
144
|
+
preserved.update(str(path) for path in releases[:keep])
|
|
145
|
+
candidates = [str(path) for path in releases if str(path) not in preserved]
|
|
146
|
+
return {"schemaVersion": "maggie-retention-plan.v1", "releaseRoot": str(root), "currentLink": str(Path(current_link)), "keep": keep, "preserved": sorted(preserved), "candidates": candidates, "mutation": "not executed", "generatedAt": datetime.now(timezone.utc).isoformat()}
|
|
147
|
+
|
|
148
|
+
|
|
104
149
|
def write_vps_plan(output_dir: Path, plan: dict) -> None:
|
|
105
150
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
106
151
|
domain = plan["domain"]
|
|
@@ -158,6 +203,9 @@ def main() -> int:
|
|
|
158
203
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
159
204
|
parser.add_argument("--node-port", type=int, default=4321)
|
|
160
205
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
206
|
+
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
207
|
+
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
208
|
+
parser.add_argument("--keep-releases", type=int, default=2)
|
|
161
209
|
args = parser.parse_args()
|
|
162
210
|
try:
|
|
163
211
|
if args.vps_plan:
|
|
@@ -168,6 +216,16 @@ def main() -> int:
|
|
|
168
216
|
write_vps_plan(Path(args.plan_dir).resolve(), plan)
|
|
169
217
|
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
170
218
|
return 0
|
|
219
|
+
if args.retention_plan:
|
|
220
|
+
if not args.current_link:
|
|
221
|
+
parser.error("--current-link is required with --retention-plan")
|
|
222
|
+
plan = retention_plan(args.release_root, args.current_link, args.keep_releases)
|
|
223
|
+
output = Path(args.output).resolve() if args.output else None
|
|
224
|
+
if output:
|
|
225
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
output.write_text(json.dumps(plan, indent=2) + "\n", encoding="utf-8")
|
|
227
|
+
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
228
|
+
return 0
|
|
171
229
|
result = preflight(Path(args.project).resolve(), args.target, args.environment)
|
|
172
230
|
if args.output:
|
|
173
231
|
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
|