diff --git a/ci/release.yml b/ci/release.yml index 4c3bdf8..5189077 100644 --- a/ci/release.yml +++ b/ci/release.yml @@ -19,6 +19,10 @@ parameters: displayName: "Auto-update users to this release" type: boolean default: false +- name: PublishStore + displayName: "Also publish to the Store" + type: boolean + default: false - name: PreTest displayName: "Pre test" type: boolean @@ -35,6 +39,10 @@ parameters: displayName: "Test Signed" type: boolean default: false +- name: StoreAppId + displayName: "Microsoft Store App Id" + type: string + default: 9NQ7512CXL7T variables: @@ -46,7 +54,8 @@ variables: PIP_VERBOSE: true PYMSBUILD_VERBOSE: true PYMSBUILD_TEMP_DIR: $(Build.BinariesDirectory) - DIST_DIR: $(Build.ArtifactStagingDirectory) + DIST_DIR: $(Build.ArtifactStagingDirectory)\dist + STORE_DIST_DIR: $(Build.ArtifactStagingDirectory)\store LAYOUT_DIR: $(Build.BinariesDirectory)\layout TEST_MSIX_DIR: $(Build.BinariesDirectory)\test_msix ${{ if ne(parameters.OverrideRef, '(tag)') }}: @@ -69,6 +78,7 @@ stages: - ${{ if eq(parameters.TestSign, 'true') }}: - group: CPythonTestSign - ${{ if eq(parameters.Publish, 'true') }}: + - group: MSFTStorePublish - group: PythonOrgPublish @@ -314,6 +324,13 @@ stages: workingDirectory: $(Pipeline.Workspace) displayName: 'Download PuTTY binaries' + - powershell: | + mv "${env:UPLOAD_DIR}\*-store.msix*" (mkdir -Force ${env:STORE_UPLOAD_DIR}) -Verbose + displayName: 'Move Store packages' + env: + UPLOAD_DIR: $(DIST_DIR) + STORE_UPLOAD_DIR: $(STORE_DIST_DIR) + - ${{ if ne(parameters.PublishAppinstaller, 'true') }}: - powershell: | "Not uploading these files:" @@ -323,11 +340,42 @@ stages: env: UPLOAD_DIR: $(DIST_DIR) + - ${{ if eq(parameters.PublishStore, 'true') }}: + - task: UseMSStoreCLI@0 + displayName: Setup Microsoft Store Developer CLI + + - powershell: > + msstore reconfigure + --tenantId $(MSSTORE_TENANT_ID) + --sellerId $(MSSTORE_SELLER_ID) + --clientId $(MSSTORE_CLIENT_ID) + --clientSecret $(MSSTORE_CLIENT_SECRET) + displayName: Authenticate Store CLI + + # We begin the submission but do not complete it, so the RM has a chance + # to update metadata before going public. It also means we can do this + # whether signed or not, since a test release can simply be deleted rather + # than published. Existing drafts will be overwritten with new ones. + - powershell: | + $msix = Get-Item "${env:STORE_UPLOAD_DIR}\*.msixupload" + "Uploading $msix" + msstore publish -v -nc -id $env:MSSTORE_APP_ID $msix + "MSIX is uploaded" + "Patching submission details" + python ci\store-publish.py + "Submission details updated" + "Finish publishing at https://partner.microsoft.com/en-us/dashboard/products/${env:MSSTORE_APP_ID}/overview" + displayName: 'Begin Store submission' + env: + STORE_UPLOAD_DIR: $(STORE_DIST_DIR) + MSSTORE_TENANT_ID: $(MSSTORE_TENANT_ID) + MSSTORE_SELLER_ID: $(MSSTORE_SELLER_ID) + MSSTORE_CLIENT_ID: $(MSSTORE_CLIENT_ID) + MSSTORE_CLIENT_SECRET: $(MSSTORE_CLIENT_SECRET) + MSSTORE_APP_ID: ${{ parameters.StoreAppId }} + PATCH_JSON: ci\store-patch.json + - powershell: | - # We don't want the Store MSIX on python.org, so just delete it - # It's already been archived in the earlier publish step, and is bundled - # into the .msixupload file. - del "${env:UPLOAD_DIR}\*-store.msix" -ErrorAction SilentlyContinue python ci\upload.py displayName: 'Publish packages' env: diff --git a/ci/store-patch.json b/ci/store-patch.json new file mode 100644 index 0000000..5f489fb --- /dev/null +++ b/ci/store-patch.json @@ -0,0 +1,11 @@ +{ + "#": "This file is used by store-upload.py to modify Store metadata.", + "listings": { + "en-us":{ + "baseListing": { + "#": "Update the release notes; 2-3 lines shown to Store users before updating", + "releaseNotes": "This update has a new \"first launch\" experience to help configure your system. If you don't see it, run \"py install --configure\" to start it manually.\n\nVisit https://github.com/python/pymanager for information and to provide feedback during beta releases." + } + } + } +} \ No newline at end of file diff --git a/ci/store-publish.py b/ci/store-publish.py new file mode 100644 index 0000000..779263a --- /dev/null +++ b/ci/store-publish.py @@ -0,0 +1,91 @@ +import json +import os +import sys + +from urllib.request import urlopen, Request + + +DIRECTORY_ID = os.environ["MSSTORE_TENANT_ID"] +CLIENT_ID = os.environ["MSSTORE_CLIENT_ID"] +CLIENT_SECRET = os.environ["MSSTORE_CLIENT_SECRET"] +SELLER_ID = os.environ["MSSTORE_SELLER_ID"] +APP_ID = os.environ["MSSTORE_APP_ID"] + +PATCH_JSON = os.environ["PATCH_JSON"] +with open(PATCH_JSON, "rb") as f: + patch_data = json.load(f) + + +SERVICE_URL = "https://manage.devcenter.microsoft.com/v1.0/my/" + +################################################################################ +# Get auth token/header +################################################################################ + +OAUTH_URL = f"https://login.microsoftonline.com/{DIRECTORY_ID}/oauth2/v2.0/token" +SERVICE_SCOPE = "https://manage.devcenter.microsoft.com/.default" + +reqAuth = Request( + OAUTH_URL, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=(f"grant_type=client_credentials&client_id={CLIENT_ID}&" + + f"client_secret={CLIENT_SECRET}&scope={SERVICE_SCOPE}").encode("utf-8"), +) + +with urlopen(reqAuth) as r: + jwt = json.loads(r.read()) + +auth = {"Authorization": f"Bearer {jwt['access_token']}"} + +################################################################################ +# Get application data (for current submission) +################################################################################ + +reqApps = Request(f"{SERVICE_URL}applications/{APP_ID}", method="GET", headers=auth) +print("Getting application data from", reqApps.full_url) +with urlopen(reqApps) as r: + app_data = json.loads(r.read()) + +submission_url = app_data["pendingApplicationSubmission"]["resourceLocation"] + +################################################################################ +# Get current submission data +################################################################################ + +reqSubmission = Request(f"{SERVICE_URL}{submission_url}", method="GET", headers=auth) +print("Getting submission data from", reqSubmission.full_url) +with urlopen(reqSubmission) as r: + sub_data = json.loads(r.read()) + +################################################################################ +# Patch submission data +################################################################################ + +if patch_data: + def _patch(target, key, src): + if key.startswith("#"): + return + if isinstance(src, dict): + for k, v in src.items(): + _patch(target.setdefault(key, {}), k, v) + else: + target[key] = src + + for k, v in patch_data.items(): + _patch(sub_data, k, v) + +################################################################################ +# Update submission data +################################################################################ + +reqUpdate = Request(f"{SERVICE_URL}{submission_url}", method="PUT", + headers={**auth, "Content-Type": "application/json; charset=utf-8"}, + data=json.dumps(sub_data).encode("utf-8")) +print("Updating submission data at", reqUpdate.full_url) +with urlopen(reqUpdate) as r: + new_data = r.read() + +new_data.pop("fileUploadUrl", None) +print("Current submission metadata:") +print(json.dumps(new_data, indent=2))