diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a2e28a..9e11df7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -392,7 +392,8 @@ jobs: run: ./tools/setup-ffmpeg.ps1 -CorrespondingSourceDestination "dist/windows/OpenStudio-FFmpeg-8.0.1-complete-corresponding-source.zip" - name: Build validated Microsoft Store package - if: vars.OPENSTUDIO_STORE_ENABLED == 'true' + # Always retain a tag-built MSIX for the first manual Store submission. + # OPENSTUDIO_STORE_ENABLED gates only the credentialed submit-store job. shell: pwsh env: RELEASE_NOTES_FILE: ${{ needs.validate-release-notes.outputs.notes_file }} @@ -406,7 +407,6 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'Store submission preflight failed.' } - name: Retain Microsoft Store submission artifact - if: vars.OPENSTUDIO_STORE_ENABLED == 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: microsoft-store-package @@ -1047,9 +1047,9 @@ jobs: {"tag":"${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || format('v{0}', github.event.inputs.version) }}","channel":"stable","desktopRepo":"${{ github.repository }}"} submit-store: - name: Submit Microsoft Store update + name: Submit Microsoft Store release needs: [publish, build-windows, validate-release-notes] - if: vars.OPENSTUDIO_STORE_ENABLED == 'true' + if: vars.OPENSTUDIO_STORE_ENABLED == 'true' && startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-24.04 timeout-minutes: 25 environment: microsoft-store @@ -1070,6 +1070,25 @@ jobs: name: microsoft-store-package path: dist/store + - name: Require the exact release tag + shell: bash + run: | + if [ "$GITHUB_REF_NAME" != "v${VERSION#v}" ]; then + echo "Store submission requires the workflow version to match its tag." >&2 + exit 1 + fi + + - name: Verify Store API access and draft without mutations + env: + MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} + MS_STORE_CLIENT_ID: ${{ secrets.MS_STORE_CLIENT_ID }} + MS_STORE_CLIENT_SECRET: ${{ secrets.MS_STORE_CLIENT_SECRET }} + run: >- + python3 tools/submit_store_release.py --version "$VERSION" + --package-dir dist/store --notes-file "$RELEASE_NOTES_FILE" + --initial-submission-config packaging/msix/initial-submission.json + --report output/store-preflight.json --preflight + - name: Submit the exact release package and notes env: MS_STORE_TENANT_ID: ${{ secrets.MS_STORE_TENANT_ID }} @@ -1078,6 +1097,7 @@ jobs: run: >- python3 tools/submit_store_release.py --version "$VERSION" --package-dir dist/store --notes-file "$RELEASE_NOTES_FILE" + --initial-submission-config packaging/msix/initial-submission.json --report output/store-submission.json --submit - name: Retain sanitized submission status @@ -1085,6 +1105,8 @@ jobs: uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: microsoft-store-submission-${{ github.run_attempt }} - path: output/store-submission.json + path: | + output/store-preflight.json + output/store-submission.json if-no-files-found: warn retention-days: 30 diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 18942ae..fe6bcd8 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -524,23 +524,71 @@ other listing settings. Publishing a GitHub release does not skip certification. 2. Build the MSIX from the Windows release payload using the existing pinned WebView2/CRT packaging checks; retain the MSIX as a workflow artifact. 3. Authenticate to the Store API using GitHub environment secrets. -4. Validate package identity, version, SHA256, and the last published Store version. -5. Clone the last published submission; replace only the x64 desktop package and - English release notes. Refuse to overwrite unrelated pending submissions. +4. Run authenticated read-only preflight: validate package identity, version, + SHA256, and either the published baseline or the explicitly pinned initial draft. +5. Clone the last published submission, or adopt the configured initial draft; + replace only the x64 desktop package and English release notes. Refuse to + overwrite unrelated pending submissions. Keep the initial publishing hold. 6. Upload a ZIP containing the MSIX, commit for certification, and report status. Resume the same tagged/hash-bound submission on retry; never blindly retry an ambiguous create/commit request or delete a pending submission. -7. Test with fake HTTP/API responses and the actual local MSIX. Run the first live - submission only after the initial manual Store submission and account setup. +7. Test with fake HTTP/API responses and the actual local MSIX. Complete the + initial Partner Center draft (including age ratings) and account setup before + the first live submission. An older published package is not a prerequisite. + +The Windows Release job always builds, validates and retains the +`microsoft-store-package` artifact. `OPENSTUDIO_STORE_ENABLED` controls only the +credentialed `submit-store` job. An MSIX packaging or offline validation failure still fails the Windows +release job, so a missing Store artifact cannot silently pass the release gate. + +### First Store release from a tag + +1. Merge the release and any release-preparation follow-up only after CI passes. + Validate `docs/releases/.md` on the final source, then push the stable + version tag on that merged `main` revision. +2. Before tagging, review `packaging/msix/initial-submission.json`. It permits only + draft `1152921505701841400`, tag `v0.1.02`, and replacement of the existing + `0.0.1.0` package. The draft must have the approved artwork fully uploaded, + age ratings and certification details completed, and publishing mode **Manual**. + Do not publish the old package to establish a baseline. +3. With the GitHub environment configured and `OPENSTUDIO_STORE_ENABLED=true`, + the `submit-store` job follows successful release publication. It downloads + the same run's `microsoft-store-package` artifact and first runs `--preflight`: + credentials are used only for authentication and Store GET requests. A failed + preflight blocks the mutation step and retains a sanitized diagnostic report. +4. The subsequent `--submit` step revalidates current state, adopts only the pinned + draft, preserves saved listing/artwork/audience/settings, replaces the package + and English release notes, and commits it for certification. The initial draft + is never deleted or recreated. Check the retained reports and Partner Center. +5. After certification, publish the qualified new version deliberately. The manual + hold prevents certification from automatically making it public. Later tags + use the normal published-baseline path; the initial pin cannot adopt other drafts. + +The config is an explicit one-release opt-in, not permission to adopt arbitrary +pending submissions. The initial path requires exactly one uploaded x64 Desktop +package at the pinned old version, saved English artwork and no unfinished assets. +Retry markers bind the package hash, notes, config and preserved settings. A changed +artifact, draft, hold or listing stops the retry; investigate instead of removing +the marker or repinning blindly. After an ambiguous PUT/upload/commit failure, +rerun the failed job using the same artifact. Already committed submissions are +polled without another upload or commit. No path publishes an older version. + +For a credentialed read-only check against a validated tagged artifact, use +`python tools/submit_store_release.py --version v0.1.02 --package-dir dist/store +--notes-file docs/releases/0.1.02.md --initial-submission-config +packaging/msix/initial-submission.json --preflight` (as one command). +Without `--preflight` or `--submit`, validation remains entirely offline. Credentials +stay in environment secrets; never pass them as arguments. Live preflight is not +evidence of certification, API update success, or Store-delivered installation. ### One-time enablement This repository implements the automation; it cannot provision the owner's Microsoft tenant or approve the initial Store listing. It stays inactive until: -1. Complete the initial manual Store submission, including age ratings and the - `runFullTrust` explanation, and publish a qualified first package. The current - `0.0.1.0` candidate is not the selected public release. +1. Complete the initial Partner Center draft, including age ratings, approved + artwork, `runFullTrust` explanation and manual publishing hold, and review the + initial-submission pin above. The `0.0.1.0` candidate is not the public release. 2. Link a Microsoft Entra application to Partner Center, assign the required Manager role, and obtain tenant ID, client ID and client secret. See Microsoft's [API prerequisites](https://learn.microsoft.com/en-us/windows/uwp/monetize/create-and-manage-submissions-using-windows-store-services). @@ -548,14 +596,19 @@ Microsoft tenant or approve the initial Store listing. It stays inactive until: Add environment secrets `MS_STORE_TENANT_ID`, `MS_STORE_CLIENT_ID`, and `MS_STORE_CLIENT_SECRET`. Add the secret directly in GitHub; never paste it in chat, commit it, or place it in workflow inputs. Rotate it before expiration. -4. Set repository Actions variable `OPENSTUDIO_STORE_ENABLED` to `true` after - the code is merged and the Store flight/installed qualification is complete. - The app identity is fixed to Store ID `9N3MQ442VXGW` and the reserved publisher. +4. After the code passes CI and merges and the selected package's release checks + are complete, set repository Actions variable `OPENSTUDIO_STORE_ENABLED` to + `true` before the release tag. The job's mandatory live preflight must pass + before submission can mutate the draft. The app identity is fixed to Store ID + `9N3MQ442VXGW` and the reserved publisher. Restrict the `microsoft-store` + environment to `v*` tags; branch runs cannot access its credentials. The job + also rejects a manually dispatched version that differs from its tag. 5. Push the normal stable release tag. The `submit-store` job follows `publish`. To require a human gate, configure required reviewers on the `microsoft-store` environment. With no reviewer gate, submission is automatic. The existing Partner Center publish mode remains authoritative after certification. +Once automation adopts the initial draft, make further updates through the API. Do not edit an API-created pending submission in Partner Center: Microsoft warns that mixing API and portal edits can invalidate it. If another submission is pending, resolve it deliberately; automation leaves it intact and fails visibly. @@ -591,8 +644,8 @@ Only the existing en-us release notes and x64 Desktop package are replaced; other architectures and listing settings remain unchanged. An accepted commit can still be in preprocessing/certification. Check Partner Center's final result. HTTP reports must exclude tokens, response bodies and SAS upload URLs. A new -artifact hash requires a new package version. Enable the workflow only after the -first manually published, qualified package and required account setup exist. +artifact hash requires a new package version. The initial draft exception requires +the reviewed one-release config; subsequent releases require a published baseline. ## Windows signing with SignPath diff --git a/docs/releases/0.1.02.md b/docs/releases/0.1.02.md index 4343637..e8bab00 100644 --- a/docs/releases/0.1.02.md +++ b/docs/releases/0.1.02.md @@ -1,9 +1,12 @@ # OpenStudio 0.1.02 -Release candidate notes for [PR #15](https://github.com/sdevil7th/OpenStudio/pull/15). These changes are not in the published v0.1.01 installers. The release preparation was committed as [adb7fd8](https://github.com/sdevil7th/OpenStudio/commit/adb7fd834ae6c9a5e8f84e7d492fd9d2d3128256), on top of [3beee3b](https://github.com/sdevil7th/OpenStudio/commit/3beee3b), which has the same tree as v0.1.01. CI fixes are being qualified in the PR; qualify its final source before tagging or publishing. +Release notes for [release PR #17](https://github.com/sdevil7th/OpenStudio/pull/17), including implementation PR #15 and documentation PR #16, covering the [application changes from v0.1.01 through merged commit 196707f](https://github.com/sdevil7th/OpenStudio/compare/v0.1.01...196707f017f0ebcd8347dfa231ee101f0094ea6f). These changes are not in the v0.1.01 installers. The release-preparation follow-up corrects these notes and makes the validated Microsoft Store package available as a Release workflow artifact even before automatic submission is enabled. Release artifacts must come from the approved v0.1.02 tag after the final preparation changes pass CI and merge; the qualification limits below remain applicable. ## Highlights +- **Updated OpenStudio branding:** refresh the application, menu-bar, browser and package icons from the approved logo. Microsoft Store listing artwork is uploaded separately; rebuilding the app does not replace an existing listing image. +- **Optional INT8 music models:** choose Original or INT8 for ACE-Step 1.5 XL Turbo, Stable Audio 3 Medium and MiniMax Music 3 on NVIDIA CUDA hardware. Setup prepares and verifies a separate saved INT8 checkpoint from the original weights; both variants can remain installed independently. This is not a smaller initial download, and selecting a missing INT8 installation does not silently fall back to Original. +- **Plugin automation capture:** record host-exposed parameter changes from native plugin editors, isolated plugin workers and built-in editors, including detached NAM Rack editors. Instrument slots have their own automation routes; recorded passes remain undoable. Parameter capture runs at control rate, not sample-accurate gesture timing. - **Interrupted-work recovery:** discover recoverable project snapshots, interrupted recordings and AI jobs. Restore a project as an unsaved copy, repair complete recorded samples into a new audio file, or restart a saved AI request. Optional periodic project backup remains off by default; recovery cannot recreate work that never reached disk. - **Click-only practice:** run the metronome while the playhead stays parked and tracks are not recording. Practice and transport share the native click generator, with explicit handover when playback or recording starts. - **Plugin fault handling:** opt into a separate process for supported external plugins. The host reports processor faults and offers explicit retry/reload paths. Isolation is optional and does not certify every vendor plugin, editor or licensing system. @@ -12,8 +15,10 @@ Release candidate notes for [PR #15](https://github.com/sdevil7th/OpenStudio/pul ## Fixes -- **MiniMax generation memory:** select resident, component-offloaded or layer-offloaded execution from available GPU memory, with RAM-aware transfer overlap. Generation details identify device family, precision and offload policy. Performance and capacity vary with request length and hardware; this does not add quantized models or new platform runtimes. -- **AI memory and progress:** Stable Audio and ACE choose placement from available memory and request duration, preserve request settings during one bounded OOM recovery attempt, and unload idle generation models after two minutes. Stable Audio source variants preserve the loaded component precisions. Generation shows actual denoising-stage progress, MiniMax frame counts and indeterminate phases instead of time-derived percentages. Status messages preserve Unicode text across Windows console encodings. Experimental quantization and compilation are not enabled. +- **MiniMax generation memory:** select resident, component-offloaded or layer-offloaded execution from available GPU memory, with RAM-aware transfer overlap. Saved INT8 execution uses disk-backed inactive weights to avoid retaining full CPU stage copies. Generation details identify device family, precision and offload policy. Performance and capacity vary with request length and hardware. +- **AI memory and progress:** Stable Audio and ACE choose placement from available memory and request duration, preserve request settings during one bounded OOM recovery attempt, and unload idle generation models after two minutes. Stable Audio source variants preserve the loaded component precisions. Generation shows actual denoising-stage progress, MiniMax frame counts and indeterminate phases instead of time-derived percentages. Status messages preserve Unicode text across Windows console encodings. Model variants are explicit; experimental compilation is not enabled. +- **Automation Read/Write and plugin saves:** empty Read lanes no longer reset plugin controls; populated Read curves reclaim manual changes, and Touch temporarily suppresses playback during gestures. Enabling Write enables Read, while disabling Write retains Read. Project saves request current native plugin state, reject missing plugin identities and report restore failures. Stopped VST3 parameter changes are retained alongside vendor state, with compatibility for older opaque state chunks. +- **Recording finalization:** retain recovery information when a WAV writer fails during finalization, and repair complete recorded samples into a separate file without overwriting the original take. - **AI runtime selection:** new Stable Audio/MiniMax setup chooses CPU, CUDA or Linux ROCm PyTorch wheels according to detected hardware. Existing ready runtimes and hardware eligibility are preserved; this does not certify generation on additional GPU families. - **Stable Audio setup:** avoid the Windows memory-mapped checkpoint reader crash during conversion. Failed or cancelled setup stays visible even when other AI tools are already installed. - **Plugin editor cleanup:** removing a built-in FX now closes its detached editor across track, input, master and monitoring chains. Editors whose slot shifts after an earlier removal are closed to prevent editing the wrong processor. Track deletion also cancels an editor reopen that was already queued. @@ -25,6 +30,8 @@ Release candidate notes for [PR #15](https://github.com/sdevil7th/OpenStudio/pul ## Known Issues +- INT8 is enabled only for NVIDIA CUDA. Other physical GPU/CPU platforms, a complete fresh native INT8 installation and subjective Original-versus-INT8 audio equivalence are not qualified. MiniMax remains demanding even with INT8 and is not promised to fit every small GPU. +- Automation does not cover master/monitor FX, JSFX slider envelopes or CLAP editor-event capture. Unexposed vendor controls need host mappings. An isolated-editor focus assertion failed during development qualification; shortcuts in focused isolated vendor windows, hidden/minimized main-window capture and loaded Kontakt/Komplete Kontrol content still need qualification. - The reported macOS Monitor FX crash and native microphone/Documents permission behavior require real-machine qualification. Windows builds and browser tests do not establish a macOS or Linux fix. - Small-buffer timing and subjective audio quality require audition on the exact device, plugin chain and build. Deterministic tests do not guarantee crackle-free operation at an 8-sample buffer or a universal track-count capacity. - Plugin isolation is optional; arbitrary plugin/editor compatibility, real OAuth flows and clean-machine upgrade behavior remain separate qualification items. Unsupported or missing optional AI dependencies must not prevent the base DAW from launching. @@ -36,4 +43,5 @@ Release candidate notes for [PR #15](https://github.com/sdevil7th/OpenStudio/pul - **Back up projects and presets before updating.** Current formats use `.osproj`, `.ospreset`, `.ostheme` and `.ospeaks`. Retired product identifiers inside saved files are unsupported by the current loader; changing an extension or folder name does not convert them. Keep the older application if you need unconverted sessions. - Automatic migration from retired NAM preset formats is not included in this release. Keep separate copies of NAM models and cabinet IRs referenced by presets. - Install the package for your distribution channel. Direct installers and Microsoft Store packages use separate update paths; do not promise automatic settings transfer between them. AI runtimes remain separate downloads and must be pinned to an already-published, compatible runtime release. +- Existing Original music-model installations remain usable. Installing INT8 needs additional disk space for preparation and its independent checkpoint; select the intended variant in setup and generation controls. - Developers preparing production builds now specify an exact version, for example `python build.py prod --version 0.1.02`. Reviewed matching release notes are checked before production/RC builds and packaging; `python build.py dev --run` remains unchanged. diff --git a/docs/store-release-activation.md b/docs/store-release-activation.md index 37a7919..b7cdd21 100644 --- a/docs/store-release-activation.md +++ b/docs/store-release-activation.md @@ -1,8 +1,106 @@ # Microsoft Store activation plan -Status recorded: September 14, 2026. +Status recorded: September 16, 2026. -## Latest account-access checkpoint +## Current release decision and blockers + +The owner explicitly does **not** want the old code or old branding published. +The old Submission 1 had passed certification with a manual publishing hold. +Its certification was cancelled to replace the old code and artwork, and Partner +Center now shows **In draft** (submission `1152921505701841400`). Nothing was +published. Do not publish the old package to establish a baseline. Release PR #17 passed its PR checks and was +merged into `main` at `196707f`. Follow-up PR #18 contains corrected +release notes, unconditional MSIX packaging/artifact retention, and explicit +first-submission support for the existing draft; its CI is +pending. Merge that preparation only after CI passes, then tag the final merged +revision. Use only the MSIX built by that tag's Release workflow. +The local candidate upload was cancelled, removed, and the removal saved and +verified in Partner Center; the draft currently retains only the old package. +Do not use the locally built `681fec8` package for submission, even though its +application source matches the release candidate. Nothing has been resubmitted. + +The English Store listing's nine logo/promotional slots were saved using only +files from the owner-designated directory +`C:\Users\srvds\OneDrive\Pictures\microsoft-store`: app tiles at 71, 150 and +300 pixels; poster at 1440x2160; box art at 2160x2160; super hero at 3840x2160; +branded key art at 584x800; titled hero at 1920x1080; and featured promotional +square at 1080x1080. Earlier website-export uploads were replaced. Reopening the +listing confirmed that all nine preview images persisted unchanged after Save. +Existing desktop screenshots and listing text were not changed. These are draft +listing updates only; the final tagged MSIX and certification submission remain +pending. The capability explanation was shortened to a complete 470-character +statement within the portal's 500-character limit. After saving, Submission +options now shows Complete. The manual publishing hold remains selected. + +The working tree was clean at `681fec8` when release preparation resumed. +`docs/releases/0.1.02.md` has been reviewed and updated for that candidate, +including the new branding, saved INT8 variants, plugin automation/state changes +and recording finalization recovery. Notes validation passed and icons were +regenerated from the approved master. These notes are preparation changes, not +evidence of a published release or a qualified installed package. + +GitHub's `microsoft-store` environment now contains all three required secrets: +`MS_STORE_TENANT_ID`, `MS_STORE_CLIENT_ID`, and `MS_STORE_CLIENT_SECRET`. +Repository variable `OPENSTUDIO_STORE_ENABLED` remains `false`. Deployment access +is restricted to tags matching `v*`, with zero branches allowed. A manually +dispatched release must use an eligible tag ref to access these credentials. +The secret value was transferred directly from Microsoft into GitHub's encrypted +environment secret; it was not printed or written to local/repository files, and +temporary in-memory transfer values were cleared afterward. + +The owner completed Entra tenant setup, authentication and association with the +existing Partner Center account. After explicit owner confirmation, the +`OpenStudio GitHub Store Release` application was created with Manager (Windows) +access and `https://openstudio.org.in/` as its Reply URL. One API key was generated, +saved in GitHub, and verified in Microsoft's masked key list; it expires on +September 16, 2028. Tenant/client identifiers and the key value are kept in the +GitHub environment secrets rather than this document. Credential provisioning +is complete, but live Store API authentication remains unverified. + +The local package checks below are supporting evidence only. PR #18 implements +the first-release path: passing CI, merge, enable submission, tag, successful +tagged Release run, authenticated read-only preflight, then submit that run's +exact MSIX into the prepared draft for certification. The new config in +`packaging/msix/initial-submission.json` permits only draft `1152921505701841400` +for `v0.1.02`, replacing the existing `0.0.1.0` package. It requires saved artwork +and the manual publishing hold, preserves listing/settings, and binds retries +to the artifact, notes and settings. Other unpublished drafts remain blocked. +The existing published-baseline path remains in use for subsequent versions. + +MSIX building works while `OPENSTUDIO_STORE_ENABLED=false`. The flag is still +false during PR review; enable it after passing CI/merge and before the release +tag. Live API access has not yet been tested: the GitHub secrets are restricted +to `v*` tags and are not readable back from GitHub. The tagged job now performs +mandatory live preflight before any Store mutation and stops on failure. Keep +the manual publishing hold through certification. The pinned `ai-runtime-v0.0.13` +release and all three platform runtime assets were confirmed available. + +September 16 local checks: 35 passed and 2 skipped across Store submission, +release-note and model-variant tests; all 5 Store package rejection tests passed. +These are local deterministic checks, not live certification or Store delivery. + +The frontend production build and CMake Release build completed with no reported +C++ compiler warnings. Runtime-bundle validation and startup prerequisite checks +passed. The candidate is +`dist/store/0.1.02-candidate/OpenStudio-0.1.2.0-x64.msix`, with SHA256 +`9e1c248b5cf7833c535e26631e65542605117a098c352c8696d29c2669bc34b5`. +MakeAppx validation, unpacked payload hash parity and the offline submission +validator passed. Matching Release binaries/PDBs are retained under +`output/symbols/Release-D8E61B90F38AFE6966404965F855FDA03A8B62241A8DB0D303B5C17632B60244`. + +The pre-existing unsigned development registration (`0.0.1.0`) was updated to +the candidate (`0.1.2.0`); no Store-signed installation was replaced. Package +identity/update-routing checks, startup prerequisites and all 42 window-lifecycle +checks passed in `output/review/store-installed-20260916-112547/`. The fixed +WebView2 runtime is used and all required browser roles reached frontend readiness. +The test process exited. This is **development-registration** evidence only: +Store certification/delivery, private-flight upgrades, clean-machine dependency +independence, hardware/audio quality and live OAuth remain **not_asserted**. + +The September 14 checkpoint and steps below are historical. Where they conflict +with this section, the current release decision above is authoritative. + +## September 14 account-access checkpoint The owner reports that the privacy URL was changed from the GitHub repository to `https://openstudio.org.in/privacy`, reviewer notes were saved, and Submission 1 diff --git a/packaging/msix/initial-submission.json b/packaging/msix/initial-submission.json new file mode 100644 index 0000000..e0ed189 --- /dev/null +++ b/packaging/msix/initial-submission.json @@ -0,0 +1,6 @@ +{ + "appId": "9N3MQ442VXGW", + "submissionId": "1152921505701841400", + "releaseTag": "v0.1.02", + "previousPackageVersion": "0.0.1.0" +} diff --git a/tests/test_store_submission.py b/tests/test_store_submission.py index ecf6b94..442e39a 100644 --- a/tests/test_store_submission.py +++ b/tests/test_store_submission.py @@ -35,7 +35,7 @@ def request(self, method, path, body=None): self.calls.append((method, path, copy.deepcopy(body))) if path == f"/applications/{store.APP_ID}": return {"id": store.APP_ID, "packageIdentityName": store.IDENTITY, "publisherName": store.PUBLISHER, - "lastPublishedApplicationSubmission": {"id": "100"}, + "lastPublishedApplicationSubmission": {"id": "100"} if self.published else None, "pendingApplicationSubmission": {"id": "200"} if self.pending else None} if path.endswith("/status"): return {"status": self.statuses.pop(0), "statusDetails": {"warnings": [{"code": "W1"}]}} @@ -275,6 +275,275 @@ def request(method, path, body=None): self.run_submit(api) self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + def initial_api(self): + pending = baseline() + pending.update(id="200", status="PendingCommit", + fileUploadUrl="https://test.blob.core.windows.net/upload?sig=SECRET") + pending["applicationPackages"][0]["version"] = "0.0.1.0" + api = FakeApi(pending=pending) + api.published = None + return api + + def initial_config(self): + return {"appId": store.APP_ID, "submissionId": "200", "releaseTag": "v0.1.02", + "previousPackageVersion": "0.0.1.0"} + + def run_initial(self, api, **kwargs): + self.run_submit(api, **({"initial_config": self.initial_config(), "release_tag": "v0.1.02"} | kwargs)) + + def test_initial_adopts_only_existing_draft_and_preserves_all_settings(self): + api = self.initial_api() + before = copy.deepcopy(api.pending) + self.run_initial(api) + self.assertEqual([method for method, _, _ in api.calls], ["GET", "GET", "GET", "PUT", "GET", "POST", "GET"]) + self.assertTrue(all(not (method == "POST" and path.endswith("/submissions")) and method != "DELETE" + for method, path, _ in api.calls)) + self.assertEqual(store.initial_settings_digest(api.pending), store.initial_settings_digest(before)) + self.assertEqual(api.pending["listings"]["en-us"]["baseListing"]["images"], + before["listings"]["en-us"]["baseListing"]["images"]) + self.assertEqual(api.pending["targetPublishMode"], "Manual") + self.assertEqual(api.uploads[0][1:], ([self.package.name], b"tested package")) + self.assertEqual(self.report["submissionId"], "200") + self.assertEqual(self.report["status"], "PreProcessing") + self.assertTrue(self.report["initialSubmission"]) + self.assertNotIn("SECRET", json.dumps(self.report)) + + def test_initial_preflight_authenticates_with_reads_only(self): + api = self.initial_api() + before = copy.deepcopy(api.pending) + self.run_initial(api, preflight_only=True) + self.assertEqual(api.pending, before) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + self.assertFalse(api.uploads) + self.assertEqual(self.report["status"], "PreflightPassed") + + def test_published_preflight_never_creates_a_submission(self): + api = FakeApi() + self.run_submit(api, preflight_only=True) + self.assertIsNone(api.pending) + self.assertFalse(api.uploads) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + self.assertEqual(self.report["status"], "PreflightPassed") + + def test_initial_requires_exact_draft_and_tag(self): + for override in ({"initial_config": None}, {"release_tag": "v0.1.2"}, {"release_tag": "v0.1.03"}, + {"initial_config": self.initial_config() | {"submissionId": "999"}}, + {"initial_config": self.initial_config() | {"releaseTag": "v0.1.03"}}): + api = self.initial_api() + with self.subTest(override=override), self.assertRaises(store.StoreError): + self.run_initial(api, **override) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + self.assertFalse(api.uploads) + + def test_initial_missing_or_wrong_response_draft_is_not_adopted(self): + for missing in (True, False): + api = self.initial_api() + if missing: + api.pending = None + else: + api.pending["id"] = "999" + with self.assertRaises(store.StoreError): + self.run_initial(api) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_initial_rejects_ineligible_draft_without_mutations(self): + def mutate(pending, case): + if case == "hold": + pending["targetPublishMode"] = "Immediate" + elif case == "status": + pending["status"] = "Certification" + elif case == "version": + pending["applicationPackages"][0]["version"] = "0.1.2.0" + elif case == "architecture": + pending["applicationPackages"][0]["architecture"] = "ARM64" + elif case == "extra_package": + pending["applicationPackages"].append(copy.deepcopy(pending["applicationPackages"][0])) + elif case == "image_upload": + pending["listings"]["en-us"]["baseListing"]["images"][0]["fileStatus"] = "PendingUpload" + elif case == "image_delete": + pending["listings"]["en-us"]["baseListing"]["images"][0]["fileStatus"] = "PendingDelete" + elif case == "missing_images": + pending["listings"]["en-us"]["baseListing"]["images"] = [] + elif case == "no_english": + pending["listings"] = {} + elif case == "foreign_marker": + pending["notesForCertification"] += "\n" + store.MARKER_PREFIX + "other artifact" + elif case == "long_notes": + pending["notesForCertification"] = "x" * 1999 + for case in ("hold", "status", "version", "architecture", "extra_package", "image_upload", "image_delete", + "missing_images", "no_english", "foreign_marker", "long_notes"): + api = self.initial_api() + mutate(api.pending, case) + with self.subTest(case=case), self.assertRaises(store.StoreError): + self.run_initial(api) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + self.assertFalse(api.uploads) + + def interrupted_initial_upload(self): + api = self.initial_api() + with patch.object(api, "upload", side_effect=store.StoreError("Upload interrupted")): + with self.assertRaisesRegex(store.StoreError, "interrupted"): + self.run_initial(api) + self.assertTrue(store.owns(api.pending, self.expected)) + self.assertEqual(api.pending["status"], "PendingCommit") + api.calls.clear() + return api + + def test_initial_interrupted_upload_resumes_without_replacing_draft_again(self): + api = self.interrupted_initial_upload() + self.run_initial(api) + self.assertEqual([method for method, _, _ in api.calls], ["GET", "GET", "POST", "GET"]) + self.assertEqual(len(api.uploads), 1) + + def test_initial_retry_preflight_is_read_only(self): + api = self.interrupted_initial_upload() + self.run_initial(api, preflight_only=True) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + self.assertFalse(api.uploads) + + def test_initial_changed_hash_notes_and_configuration_fail_on_retry(self): + for change in ("hash", "notes", "configuration"): + api = self.interrupted_initial_upload() + with self.subTest(change=change), self.assertRaises(store.StoreError): + if change == "hash": + changed = self.directory / "changed" / self.package.name + changed.parent.mkdir(exist_ok=True) + changed.write_bytes(b"different release artifact") + store.submit(api, changed, "0.1.2.0", store.digest(changed), self.notes, lambda **_: None, + initial_config=self.initial_config(), release_tag="v0.1.02") + elif change == "notes": + store.submit(api, self.package, "0.1.2.0", self.sha, "changed", lambda **_: None, + initial_config=self.initial_config(), release_tag="v0.1.02") + else: + self.run_initial(api, initial_config=self.initial_config() | {"previousPackageVersion": "0.0.2.0"}) + self.assertFalse(api.uploads) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_initial_saved_artwork_hold_notes_or_packages_changed_on_retry(self): + for change in ("artwork", "hold", "notes", "package"): + api = self.interrupted_initial_upload() + if change == "artwork": + api.pending["listings"]["en-us"]["baseListing"]["images"][0]["id"] = "changed" + elif change == "hold": + api.pending["targetPublishMode"] = "Immediate" + elif change == "notes": + api.pending["listings"]["en-us"]["baseListing"]["releaseNotes"] = "changed" + else: + api.pending["applicationPackages"][-1]["fileName"] = "wrong.msix" + with self.subTest(change=change), self.assertRaises(store.StoreError): + self.run_initial(api) + self.assertFalse(api.uploads) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_initial_ambiguous_put_resumes_from_saved_marker(self): + api = self.initial_api() + request = api.request + def ambiguous(method, path, body=None): + result = request(method, path, body) + if method == "PUT": + raise store.StoreError("Timed out after PUT") + return result + with patch.object(api, "request", side_effect=ambiguous), self.assertRaises(store.StoreError): + self.run_initial(api) + self.assertFalse(api.uploads) + api.calls.clear() + self.run_initial(api) + self.assertFalse(any(method == "PUT" for method, _, _ in api.calls)) + self.assertEqual(len(api.uploads), 1) + + def test_initial_ambiguous_commit_polls_without_recommitting(self): + api = self.initial_api() + request = api.request + def ambiguous(method, path, body=None): + result = request(method, path, body) + if path.endswith("/commit"): + raise store.StoreError("Timed out after commit") + return result + with patch.object(api, "request", side_effect=ambiguous), self.assertRaises(store.StoreError): + self.run_initial(api) + api.calls.clear() + self.run_initial(api) + self.assertEqual(len(api.uploads), 1) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_initial_changed_during_preflight_or_after_put_stops_before_upload(self): + for after_put in (False, True): + api = self.initial_api() + request = api.request + draft_reads = 0 + def changing(method, path, body=None): + nonlocal draft_reads + if method == "GET" and path.endswith("/200"): + draft_reads += 1 + if draft_reads == (3 if after_put else 2): + api.pending["visibility"] = "Public" + return request(method, path, body) + with patch.object(api, "request", side_effect=changing), self.assertRaises(store.StoreError): + self.run_initial(api) + self.assertFalse(api.uploads) + self.assertFalse(any(method == "POST" for method, _, _ in api.calls)) + if not after_put: + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_initial_failed_certification_is_not_resubmitted(self): + api = self.initial_api() + self.run_initial(api) + api.pending["status"] = "CertificationFailed" + api.calls.clear() + with self.assertRaisesRegex(store.StoreError, "CertificationFailed"): + self.run_initial(api) + self.assertEqual(len(api.uploads), 1) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_published_release_uses_regular_path_even_with_initial_config(self): + api = FakeApi() + self.run_initial(api) + self.assertFalse(self.report["initialSubmission"]) + self.assertTrue(any(method == "POST" and path.endswith("/submissions") for method, path, _ in api.calls)) + + def test_initial_config_validation_and_repository_pin(self): + actual = store.load_initial_config(store.ROOT / "packaging/msix/initial-submission.json") + self.assertEqual(actual["submissionId"], "1152921505701841400") + self.assertEqual(actual["releaseTag"], "v0.1.02") + for change in ({"appId": "other"}, {"submissionId": "200/commit"}, {"submissionId": 200}, + {"releaseTag": "v0.1.02-beta"}, {"previousPackageVersion": "bad"}, {"extra": "field"}): + path = self.directory / "config.json" + path.write_text(json.dumps(self.initial_config() | change)) + with self.subTest(change=change), self.assertRaises(store.StoreError): + store.load_initial_config(path) + + def test_package_changed_after_validation_never_contacts_store(self): + api = self.initial_api() + self.package.write_bytes(b"replaced after validation") + with self.assertRaisesRegex(store.StoreError, "changed after validation"): + self.run_initial(api) + self.assertFalse(api.calls) + self.assertFalse(api.uploads) + + def test_initial_cli_preflight_and_submit_use_explicit_config(self): + self.create_package() + config_path = self.directory / "initial.json" + config_path.write_text(json.dumps(self.initial_config())) + for mode in ("--preflight", "--submit"): + api = self.initial_api() + report_path = self.directory / "cli-report.json" + with patch.object(store, "StoreApi", return_value=api), patch("sys.argv", [ + "submit_store_release.py", "--version", "v0.1.02", "--package-dir", str(self.directory), + "--notes-file", str(store.ROOT / "docs/releases/0.1.02.md"), + "--initial-submission-config", str(config_path), "--report", str(report_path), mode]): + self.assertEqual(store.main(), 0) + report = json.loads(report_path.read_text()) + self.assertNotIn("SECRET", json.dumps(report)) + self.assertEqual(report["livePreflight"], mode == "--preflight") + self.assertEqual(report["liveSubmission"], mode == "--submit") + if mode == "--preflight": + self.assertEqual(report["status"], "PreflightPassed") + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + self.assertFalse(api.uploads) + else: + self.assertEqual(report["status"], "PreProcessing") + self.assertEqual(len(api.uploads), 1) + if __name__ == "__main__": unittest.main() diff --git a/tools/submit_store_release.py b/tools/submit_store_release.py index 5ecfd38..82b91a5 100644 --- a/tools/submit_store_release.py +++ b/tools/submit_store_release.py @@ -1,6 +1,7 @@ """Validate an OpenStudio MSIX and optionally submit it to Microsoft Store. -Default: offline validation only. --submit is the sole network/mutation opt-in. +Default: offline validation only. --preflight permits authenticated reads; +--submit permits mutations. Initial drafts require an explicit reviewed config. Secrets come from the environment. Reports never contain bearer tokens or SAS URLs. """ from __future__ import annotations @@ -30,6 +31,7 @@ FAILED = {"CommitFailed", "PreProcessingFailed", "CertificationFailed", "PublishFailed", "Canceled"} ACCEPTED = {"PreProcessing", "Certification", "PendingPublication", "Publishing", "Published", "Release"} MARKER_PREFIX = "OpenStudio release automation: " +INITIAL_MARKER_PREFIX = "OpenStudio initial draft: " class StoreError(RuntimeError): @@ -119,6 +121,99 @@ def owns(submission: dict, expected: str) -> bool: return expected in submission.get("notesForCertification", "").splitlines() +def load_initial_config(path: Path) -> dict: + config = json.loads(path.read_text(encoding="utf-8-sig")) + if (not isinstance(config, dict) or set(config) != { + "appId", "submissionId", "releaseTag", "previousPackageVersion"} + or not all(isinstance(value, str) for value in config.values()) + or config["appId"] != APP_ID or not re.fullmatch(r"\d+", config["submissionId"]) + or not config["releaseTag"].startswith("v")): + raise StoreError("Invalid initial-submission configuration.") + package_version(config["releaseTag"]) + version_tuple(config["previousPackageVersion"]) + return config + + +def json_digest(value: dict) -> str: + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + + +def initial_settings_digest(submission: dict) -> str: + # Bind the saved listing/artwork/audience/ratings and other settings to retries. + # Only the package, English release notes, markers and server status may change. + settings = copy.deepcopy(submission) + for key in ("id", "status", "statusDetails", "fileUploadUrl", "applicationPackages"): + settings.pop(key, None) + settings["notesForCertification"] = "\n".join( + line for line in settings.get("notesForCertification", "").splitlines() + if not line.startswith((MARKER_PREFIX, INITIAL_MARKER_PREFIX))).strip() + for language, listing in settings.get("listings", {}).items(): + if language.lower() == "en-us": + listing.get("baseListing", {}).pop("releaseNotes", None) + return json_digest(settings) + + +def require_uploaded_assets(value): + if isinstance(value, dict): + if "fileStatus" in value and value["fileStatus"] != "Uploaded": + raise StoreError("Initial draft contains unfinished or deleted assets; finish reviewing it in Partner Center.") + for child in value.values(): + require_uploaded_assets(child) + elif isinstance(value, list): + for child in value: + require_uploaded_assets(child) + + +def initial_marker(config: dict, submission: dict) -> str: + return f"{INITIAL_MARKER_PREFIX}{json_digest(config)}; settings={initial_settings_digest(submission)}" + + +def validate_initial_resume(pending: dict, config: dict, package: Path, notes: str, expected: str): + release_markers = [line for line in pending.get("notesForCertification", "").splitlines() + if line.startswith(MARKER_PREFIX)] + if (pending.get("id") != config["submissionId"] or pending.get("targetPublishMode") != "Manual" + or release_markers != [expected]): + raise StoreError("Initial submission identity, release marker or manual publishing hold changed.") + markers = [line for line in pending.get("notesForCertification", "").splitlines() + if line.startswith(INITIAL_MARKER_PREFIX)] + if markers != [initial_marker(config, pending)]: + raise StoreError("Initial submission settings/artwork or configuration changed; refusing to resume.") + if pending.get("status") == "PendingCommit": + packages = pending.get("applicationPackages", []) + old = [item for item in packages if item.get("fileStatus") == "PendingDelete"] + new = [item for item in packages if item.get("fileStatus") == "PendingUpload"] + if (len(packages) != 2 or len(old) != 1 or len(new) != 1 + or old[0].get("version") != config["previousPackageVersion"] + or new[0].get("fileName") != package.name): + raise StoreError("Initial submission package replacement changed; refusing to upload or commit.") + english = [value for language, value in pending.get("listings", {}).items() if language.lower() == "en-us"] + if len(english) != 1 or english[0].get("baseListing", {}).get("releaseNotes") != notes: + raise StoreError("Initial submission release notes changed; refusing to commit.") + + +def prepare_initial_submission(pending: dict, config: dict, package: Path, version: str, + notes: str, expected: str) -> dict: + if pending.get("id") != config["submissionId"] or pending.get("targetPublishMode") != "Manual": + raise StoreError("Initial draft identity or manual publishing hold does not match.") + if pending.get("status") != "PendingCommit": + raise StoreError("The initial draft is not editable; automation will not cancel certification.") + if any(line.startswith((MARKER_PREFIX, INITIAL_MARKER_PREFIX)) + for line in pending.get("notesForCertification", "").splitlines()): + raise StoreError("Initial draft belongs to another artifact or release; refusing to overwrite it.") + packages = pending.get("applicationPackages", []) + if (len(packages) != 1 or packages[0].get("version") != config["previousPackageVersion"] + or version_tuple(version) <= version_tuple(config["previousPackageVersion"])): + raise StoreError("Initial draft package version/coverage differs from the reviewed configuration.") + require_uploaded_assets(pending) + english = [value for language, value in pending.get("listings", {}).items() if language.lower() == "en-us"] + if len(english) != 1 or not english[0].get("baseListing", {}).get("images"): + raise StoreError("Initial draft must contain the saved English listing artwork.") + updated = copy.deepcopy(pending) + updated["notesForCertification"] = (updated.get("notesForCertification", "").rstrip() + + "\n" + initial_marker(config, pending)).strip() + return prepare_submission(updated, package, notes, expected) + + def prepare_submission(submission: dict, package: Path, notes: str, expected: str) -> dict: updated = copy.deepcopy(submission) packages = updated.get("applicationPackages", []) @@ -211,40 +306,73 @@ def submission_path(submission_id: str) -> str: def submit(api, package: Path, version: str, sha256: str, notes: str, record, - *, max_polls=40, sleep=time.sleep): + *, initial_config=None, release_tag=None, preflight_only=False, max_polls=40, sleep=time.sleep): base = f"/applications/{APP_ID}" expected = marker(version, sha256, notes) + if digest(package) != sha256: + raise StoreError("Package changed after validation; refusing to contact the Store.") app = api.request("GET", base) if app.get("id") != APP_ID or app.get("packageIdentityName") != IDENTITY or app.get("publisherName") != PUBLISHER: raise StoreError("Partner Center app identity does not match OpenStudio.") published_id = (app.get("lastPublishedApplicationSubmission") or {}).get("id") - if not published_id: - raise StoreError("Complete and publish the first manual Store submission before enabling automation.") - published = api.request("GET", submission_path(published_id)) - if owns(published, expected): - record(submissionId=published_id, status="Published", alreadySubmitted=True) - return - for item in published.get("applicationPackages", []): - if item.get("fileStatus") != "PendingDelete" and version_tuple(item.get("version", "")) >= version_tuple(version): - raise StoreError("The Store already has this version or a newer version. Publish a higher version.") pending_id = (app.get("pendingApplicationSubmission") or {}).get("id") - if pending_id: + initial = not published_id + if not published_id: + if (not initial_config or pending_id != initial_config["submissionId"] + or release_tag != initial_config["releaseTag"] + or version != package_version(initial_config["releaseTag"])): + raise StoreError("No published baseline: an exact initial draft and release tag must be explicitly configured.") pending = api.request("GET", submission_path(pending_id)) - if not owns(pending, expected): - raise StoreError("An unrelated or unmarked submission is pending. Resolve it manually; automation will not overwrite or delete it.") + if owns(pending, expected): + validate_initial_resume(pending, initial_config, package, notes, expected) + else: + prepared = prepare_initial_submission(pending, initial_config, package, version, notes, expected) + if preflight_only: + record(submissionId=pending_id, status="PreflightPassed", initialSubmission=True) + return + # Detect edits made since the initial read before adopting the draft. + current = api.request("GET", submission_path(pending_id)) + if (current.get("status") != "PendingCommit" or current.get("id") != pending_id + or initial_settings_digest(current) != initial_settings_digest(pending) + or current.get("applicationPackages") != pending.get("applicationPackages") + or current.get("notesForCertification") != pending.get("notesForCertification")): + raise StoreError("Initial draft changed during preflight; no changes were made.") + api.request("PUT", submission_path(pending_id), prepared) + # Read back the server's state and current upload URL; never assume PUT succeeded. + pending = api.request("GET", submission_path(pending_id)) + validate_initial_resume(pending, initial_config, package, notes, expected) else: - # Validate preservation/coverage/notes before making the first mutation. - prepare_submission(published, package, notes, expected) - pending = api.request("POST", base + "/submissions") - pending_id = pending["id"] - record(submissionId=pending_id, status="Created") - pending = prepare_submission(pending, package, notes, expected) - api.request("PUT", submission_path(pending_id), pending) + published = api.request("GET", submission_path(published_id)) + if owns(published, expected): + record(submissionId=published_id, status="Published", alreadySubmitted=True) + return + for item in published.get("applicationPackages", []): + if item.get("fileStatus") != "PendingDelete" and version_tuple(item.get("version", "")) >= version_tuple(version): + raise StoreError("The Store already has this version or a newer version. Publish a higher version.") + if pending_id: + pending = api.request("GET", submission_path(pending_id)) + if not owns(pending, expected): + raise StoreError("An unrelated or unmarked submission is pending. Resolve it manually; automation will not overwrite or delete it.") + else: + # Validate preservation/coverage/notes before making the first mutation. + prepare_submission(published, package, notes, expected) + if preflight_only: + record(status="PreflightPassed", initialSubmission=False) + return + pending = api.request("POST", base + "/submissions") + pending_id = pending["id"] + record(submissionId=pending_id, status="Created") + pending = prepare_submission(pending, package, notes, expected) + api.request("PUT", submission_path(pending_id), pending) path = submission_path(pending_id) - record(submissionId=pending_id, status=pending.get("status", "Unknown")) status = pending.get("status") if status in FAILED: raise StoreError(f"Existing submission is {status}; inspect certification details before retrying.") + if status not in ACCEPTED | {"PendingCommit", "CommitStarted"}: + raise StoreError("Unexpected submission state; inspect Partner Center before continuing.") + record(submissionId=pending_id, status="PreflightPassed" if preflight_only else status, initialSubmission=initial) + if preflight_only: + return if status == "PendingCommit": # Safe to re-upload the same hash-bound ZIP after an interrupted upload. with tempfile.TemporaryDirectory(prefix="openstudio-store-") as temp: @@ -281,9 +409,12 @@ def main(): parser.add_argument("--notes-file", type=Path) parser.add_argument("--report", type=Path, default=Path("output/store-submission.json")) parser.add_argument("--print-package-version", action="store_true") - parser.add_argument("--submit", action="store_true") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--submit", action="store_true") + mode.add_argument("--preflight", action="store_true", help="Authenticate and inspect readiness without modifying the Store.") + parser.add_argument("--initial-submission-config", type=Path) args = parser.parse_args() - report = {"appId": APP_ID, "liveSubmission": args.submit} + report = {"appId": APP_ID, "liveSubmission": args.submit, "livePreflight": args.preflight} def record(**values): report.update(values) @@ -302,8 +433,10 @@ def record(**values): tag = "v" + args.version.removeprefix("v") notes = store_notes(notes, tag) record(version=version, sha256=sha256, releaseTag=tag, status="Validated", releaseNotes=notes) - if args.submit: - submit(StoreApi(), package, version, sha256, notes, record) + config = load_initial_config(args.initial_submission_config) if args.initial_submission_config else None + if args.submit or args.preflight: + submit(StoreApi(), package, version, sha256, notes, record, initial_config=config, + release_tag=tag, preflight_only=args.preflight) print(f"Store release: {report['status']}; report: {args.report}") return 0 except (StoreError, OSError, ValueError, KeyError, zipfile.BadZipFile, ET.ParseError) as error: