Skip to content

Commit 4dfca9f

Browse files
committed
Fixes #9 Implement publish to python.org
1 parent 5f38dab commit 4dfca9f

File tree

2 files changed

+228
-3
lines changed

2 files changed

+228
-3
lines changed

ci/release.yml

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,30 @@ jobs:
279279
Get-AppxPackage PythonSoftwareFoundation.PythonManager | Remove-AppxPackage
280280
displayName: 'Remove MSIX'
281281
282-
- ${{ if and(eq(parameters.Publish, 'true'), eq(parameters.Sign, 'true')) }}:
282+
- ${{ if eq(parameters.Publish, 'true') }}:
283+
- task: DownloadSecureFile@1
284+
name: sshkey
285+
inputs:
286+
secureFile: pydotorg-ssh.ppk
287+
displayName: 'Download PuTTY key'
288+
283289
- powershell: |
284-
Write-Host "TODO: Publish packages"
285-
displayName: 'TODO: Publish packages'
290+
git clone https://github.com/python/cpython-bin-deps --branch putty --single-branch --depth 1 --progress -v "putty"
291+
"##vso[task.prependpath]$(gi putty)"
292+
workingDirectory: $(Pipeline.Workspace)
293+
displayName: 'Download PuTTY binaries'
294+
295+
- powershell: |
296+
python ci\upload.py
297+
displayName: 'Publish packages'
298+
env:
299+
UPLOAD_URL: $(PyDotOrgUrlPrefix)python/pymanager
300+
UPLOAD_DIR: $(DIST_DIR)
301+
UPLOAD_URL_PREFIX: $(PyDotOrgUrlPrefix)
302+
UPLOAD_PATH_PREFIX: $(PyDotOrgUploadPathPrefix)
303+
UPLOAD_HOST: $(PyDotOrgServer)
304+
UPLOAD_HOST_KEY: $(PyDotOrgHostKey)
305+
UPLOAD_USER: $(PyDotOrgUsername)
306+
UPLOAD_KEYFILE: $(sshkey.secureFilePath)
307+
${{ if ne(parameters.Sign, 'true') }}:
308+
NO_UPLOAD: 1

ci/upload.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import os
2+
import subprocess
3+
import sys
4+
from pathlib import Path
5+
from xml.etree import ElementTree as ET
6+
7+
UPLOAD_URL_PREFIX = os.getenv("UPLOAD_URL_PREFIX", "https://www.python.org/ftp/")
8+
UPLOAD_PATH_PREFIX = os.getenv("UPLOAD_PATH_PREFIX", "/srv/www.python.org/ftp/")
9+
UPLOAD_URL = os.getenv("UPLOAD_URL")
10+
UPLOAD_DIR = os.getenv("UPLOAD_DIR")
11+
# A version will be inserted before the extension later on
12+
MANIFEST_FILE = os.getenv("MANIFEST_FILE")
13+
UPLOAD_HOST = os.getenv("UPLOAD_HOST", "")
14+
UPLOAD_HOST_KEY = os.getenv("UPLOAD_HOST_KEY", "")
15+
UPLOAD_KEYFILE = os.getenv("UPLOAD_KEYFILE", "")
16+
UPLOAD_USER = os.getenv("UPLOAD_USER", "")
17+
NO_UPLOAD = os.getenv("NO_UPLOAD", "no")[:1].lower() in "yt1"
18+
19+
20+
if not UPLOAD_URL:
21+
print("##[error]Cannot upload without UPLOAD_URL")
22+
sys.exit(1)
23+
24+
25+
def find_cmd(env, exe):
26+
cmd = os.getenv(env)
27+
if cmd:
28+
return Path(cmd)
29+
for p in os.getenv("PATH", "").split(";"):
30+
if p:
31+
cmd = Path(p) / exe
32+
if cmd.is_file():
33+
return cmd
34+
if UPLOAD_HOST:
35+
raise RuntimeError(
36+
f"Could not find {exe} to perform upload. Try setting %{env}% or %PATH%"
37+
)
38+
print(f"Did not find {exe}, but not uploading anyway.")
39+
40+
41+
PLINK = find_cmd("PLINK", "plink.exe")
42+
PSCP = find_cmd("PSCP", "pscp.exe")
43+
44+
45+
def _std_args(cmd):
46+
if not cmd:
47+
raise RuntimeError("Cannot upload because command is missing")
48+
all_args = [cmd, "-batch"]
49+
if UPLOAD_HOST_KEY:
50+
all_args.append("-hostkey")
51+
all_args.append(UPLOAD_HOST_KEY)
52+
if UPLOAD_KEYFILE:
53+
all_args.append("-noagent")
54+
all_args.append("-i")
55+
all_args.append(UPLOAD_KEYFILE)
56+
return all_args
57+
58+
59+
class RunError(Exception):
60+
pass
61+
62+
63+
def _run(*args):
64+
with subprocess.Popen(
65+
args,
66+
stdout=subprocess.PIPE,
67+
stderr=subprocess.STDOUT,
68+
encoding="ascii",
69+
errors="replace",
70+
) as p:
71+
out, _ = p.communicate(None)
72+
if out:
73+
print(out.encode("ascii", "replace").decode("ascii"))
74+
if p.returncode:
75+
raise RunError(p.returncode, out)
76+
77+
78+
def call_ssh(*args, allow_fail=True):
79+
if not UPLOAD_HOST or NO_UPLOAD or LOCAL_INDEX:
80+
print("Skipping", args, "because UPLOAD_HOST is missing")
81+
return
82+
try:
83+
_run(*_std_args(PLINK), f"{UPLOAD_USER}@{UPLOAD_HOST}", *args)
84+
except RunError:
85+
if not allow_fail:
86+
raise
87+
88+
89+
def upload_ssh(source, dest):
90+
if not UPLOAD_HOST or NO_UPLOAD or LOCAL_INDEX:
91+
print("Skipping upload of", source, "because UPLOAD_HOST is missing")
92+
return
93+
_run(*_std_args(PSCP), source, f"{UPLOAD_USER}@{UPLOAD_HOST}:{dest}")
94+
call_ssh(f"chgrp downloads {dest} && chmod g-x,o+r {dest}")
95+
96+
97+
def download_ssh(source, dest):
98+
if not UPLOAD_HOST:
99+
print("Skipping download of", source, "because UPLOAD_HOST is missing")
100+
return
101+
Path(dest).parent.mkdir(exist_ok=True, parents=True)
102+
_run(*_std_args(PSCP), f"{UPLOAD_USER}@{UPLOAD_HOST}:{source}", dest)
103+
104+
105+
def ls_ssh(dest):
106+
if not UPLOAD_HOST or LOCAL_INDEX:
107+
print("Skipping ls of", dest, "because UPLOAD_HOST is missing")
108+
return
109+
try:
110+
_run(*_std_args(PSCP), "-ls", f"{UPLOAD_USER}@{UPLOAD_HOST}:{dest}")
111+
except RunError as ex:
112+
if not ex.args[1].rstrip().endswith("No such file or directory"):
113+
raise
114+
print(dest, "was not found")
115+
116+
117+
def url2path(url):
118+
if not UPLOAD_URL_PREFIX:
119+
raise ValueError("%UPLOAD_URL_PREFIX% was not set")
120+
if not url:
121+
raise ValueError("Unexpected empty URL")
122+
if not url.startswith(UPLOAD_URL_PREFIX):
123+
if LOCAL_INDEX:
124+
return url
125+
raise ValueError(f"Unexpected URL: {url}")
126+
return UPLOAD_PATH_PREFIX + url[len(UPLOAD_URL_PREFIX) :]
127+
128+
129+
def validate_appinstaller(file, uploads):
130+
NS = {}
131+
with open(file, "r", encoding="utf-8") as f:
132+
NS = dict(e for _, e in ET.iterparse(f, events=("start-ns",)))
133+
for k, v in NS.items():
134+
ET.register_namespace(k, v)
135+
NS["x"] = NS[""]
136+
137+
with open(file, "r", encoding="utf-8") as f:
138+
xml = ET.parse(f)
139+
140+
self_uri = xml.find(".[@Uri]", NS).get("Uri")
141+
if not self_uri:
142+
print("##[error]Empty Uri attribute in appinstaller file")
143+
sys.exit(2)
144+
if not any(
145+
u.casefold() == self_uri.casefold() and f == file
146+
for f, u, _ in uploads
147+
):
148+
print("##[error]Uri", self_uri, "in appinstaller file is not where "
149+
"the appinstaller file is being uploaded.")
150+
sys.exit(2)
151+
152+
main = xml.find("x:MainPackage[@Uri]", NS)
153+
if main is None:
154+
print("##[error]No MainPackage element with Uri in appinstaller file")
155+
sys.exit(2)
156+
package_uri = main.get("Uri")
157+
if not package_uri:
158+
print("##[error]Empty Mainpackage.Uri attribute in appinstaller file")
159+
sys.exit(2)
160+
if package_uri.casefold() not in [u.casefold() for _, u, _ in uploads]:
161+
print("##[error]Uri", package_uri, "in appinstaller file is not being uploaded")
162+
sys.exit(2)
163+
164+
print(file, "checked:")
165+
print("-", package_uri, "is part of this upload")
166+
print("-", self_uri, "is the destination of this file")
167+
print()
168+
169+
170+
def purge(url):
171+
if not UPLOAD_HOST or NO_UPLOAD:
172+
print("Skipping purge of", url, "because UPLOAD_HOST is missing")
173+
return
174+
with urlopen(Request(url, method="PURGE", headers={"Fastly-Soft-Purge": 1})) as r:
175+
r.read()
176+
177+
178+
UPLOAD_DIR = Path(UPLOAD_DIR).absolute()
179+
UPLOAD_URL = UPLOAD_URL.rstrip("/") + "/"
180+
181+
UPLOADS = []
182+
183+
for pat in ("python-manager-*.msix", "python-manager-*.msi", "pymanager.appinstaller"):
184+
for f in UPLOAD_DIR.glob(pat):
185+
u = UPLOAD_URL + f.name
186+
UPLOADS.append((f, u, url2path(u)))
187+
188+
print("Planned uploads:")
189+
for f, u, p in UPLOADS:
190+
print(f"{f} -> {p}")
191+
print(f" Final URL: {u}")
192+
print()
193+
194+
for f, *_ in UPLOADS:
195+
if f.match("*.appinstaller"):
196+
validate_appinstaller(f, UPLOADS)
197+
198+
for f, u, p in UPLOADS:
199+
print("Upload", f, "to", p)
200+
upload_ssh(f, p)
201+
print("Purge", u)
202+
purge(u)

0 commit comments

Comments
 (0)