From 52cbd7c112477ba0c139f200159d8ccc545accac Mon Sep 17 00:00:00 2001 From: Sourav Das Date: Wed, 16 Sep 2026 20:21:33 +0530 Subject: [PATCH] Gate release publication on live Store preflight --- .github/workflows/release.yml | 52 ++++++++++++++++++++++++++++++++ docs/release-runbook.md | 23 ++++++++++---- docs/store-release-activation.md | 45 ++++++++++++++++++++++++++- tests/test_store_submission.py | 47 +++++++++++++++++++++++++++++ tools/submit_store_release.py | 21 +++++++++++-- 5 files changed, 178 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e11df7..b496017 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -820,13 +820,65 @@ jobs: path: dist/linux/OpenStudio-*-linux-x86_64.AppImage if-no-files-found: error + preflight-store: + name: Check Store readiness before publication + needs: [build-windows, validate-release-notes] + if: vars.OPENSTUDIO_STORE_ENABLED == 'true' && startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: microsoft-store + permissions: + contents: read + actions: read + env: + VERSION: ${{ github.event.inputs.version || github.ref_name }} + RELEASE_NOTES_FILE: ${{ needs.validate-release-notes.outputs.notes_file }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: microsoft-store-package + path: dist/store + - name: Require the exact release tag + run: test "$GITHUB_REF_NAME" = "v${VERSION#v}" + - name: Inspect Store readiness without changing the submission + 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-prepublish.json --preflight + - name: Retain sanitized readiness evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: microsoft-store-readiness-${{ github.run_attempt }} + path: output/store-prepublish.json + if-no-files-found: warn + retention-days: 30 + publish: + # A skipped Store gate is permitted only for releases where Store submission + # is not enabled. Failed/cancelled builds or preflight must never publish. + if: >- + ${{ !cancelled() && needs.validate-release-notes.result == 'success' + && needs.build-windows.result == 'success' + && needs.build-macos.result == 'success' + && needs.build-linux.result == 'success' + && (needs.preflight-store.result == 'success' + || (needs.preflight-store.result == 'skipped' + && (vars.OPENSTUDIO_STORE_ENABLED != 'true' + || !startsWith(github.ref, 'refs/tags/v')))) }} runs-on: ubuntu-latest needs: - validate-release-notes - build-windows - build-macos - build-linux + - preflight-store permissions: contents: write env: diff --git a/docs/release-runbook.md b/docs/release-runbook.md index 6426504..2db561e 100644 --- a/docs/release-runbook.md +++ b/docs/release-runbook.md @@ -526,6 +526,8 @@ other listing settings. Publishing a GitHub release does not skip certification. 3. Authenticate to the Store API using GitHub environment secrets. 4. Run authenticated read-only preflight: validate package identity, version, SHA256, and either the published baseline or the explicitly pinned initial draft. + The `preflight-store` job must pass before GitHub publication when Store delivery + is enabled. Its sanitized report records observed Store state and blocking errors. 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. @@ -537,12 +539,20 @@ other listing settings. Publishing a GitHub release does not skip certification. 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 +`microsoft-store-package` artifact. `OPENSTUDIO_STORE_ENABLED` controls the +credentialed `preflight-store` and `submit-store` jobs. 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 +The `v0.1.03` initial-draft attempt failed its live state check and was completed +through the portal using the exact tag-built MSIX. It is not proof that the API +can adopt this portal draft. Leave its current certification intact; see the +[current activation status and acceptance criteria](store-release-activation.md) +before another tag. The initial path below is a guarded capability, not a verified +live result. Subsequent releases use the published-baseline path once the first +version is deliberately published. + 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. @@ -552,10 +562,11 @@ release job, so a missing Store artifact cannot silently pass the release gate. 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. + `preflight-store` downloads the same run's `microsoft-store-package` and runs + `--preflight` before publication: credentials are used only for authentication + and Store GET requests. A failed check blocks GitHub publication and retains + a sanitized diagnostic report. After publication, `submit-store` repeats the + check before any mutation to detect intervening Store changes. 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 diff --git a/docs/store-release-activation.md b/docs/store-release-activation.md index 437b5d4..82004a2 100644 --- a/docs/store-release-activation.md +++ b/docs/store-release-activation.md @@ -2,7 +2,50 @@ Status recorded: September 16, 2026. -## Current release status after the failed v0.1.02 run +## Current status and the evidence still needed + +PR #20 merged at `3aaf47e324461ca7b63848b0be10327e8305cf4f` and the +`v0.1.03` tag points to that commit. All three platform builds and GitHub +publication passed in [Release #45](https://github.com/sdevil7th/OpenStudio/actions/runs/35099203025). +The website publish job passed. Post-merge Verify passed after one Windows +browser-test timeout was rerun on unchanged source (100 browser tests passed). + +**Store automation is configured, but end-to-end submission is not yet proven.** +The tag automatically started the Store job and authenticated successfully. +Its read-only preflight stopped because the initial draft was not `PendingCommit`. +That run did not record the actual API state, so do not infer a specific state +from the portal's **In draft** label. No Store mutation ran in GitHub. + +The browser fallback uploaded the same run's `OpenStudio-0.1.3.0-x64.msix`, +removed the `0.0.1.0` placeholder, saved current release/certification notes, +and submitted draft `1152921505701841400`. Package SHA-256: +`f84c9ecf4bf85a3c035400f9759af8351a47ac1506331da975ceaab8a008444e`. +The nine approved artwork slots were retained. Partner Center subsequently +showed **Certification in progress**, with public publication held until +**Publish now**. This was a manual portal submission, not an automation pass. + +The follow-up workflow runs `preflight-store` against the exact tagged MSIX +before GitHub publication. Failure blocks publication and retains sanitized +state/error evidence. The submit job still repeats preflight immediately before +mutation, because Store state can change between jobs. This check cannot prove +upload/commit permissions or acceptance; a successful live submission must do that. + +To qualify automation after this first version is certified and deliberately +published, use the next reviewed higher-version release, with no unrelated Store +draft pending. Require all of the following evidence from that tag's run: + +1. `preflight-store` passes using the protected `microsoft-store` environment. +2. `submit-store` uploads and commits without a browser fallback, and finishes + successfully with a real submission ID and an accepted ingestion state. +3. Its retained report matches the tag, normalized package version and MSIX hash. +4. Partner Center shows the same submission/version entering preprocessing or + certification, with the intended artwork and publication hold. + +Only then record **automated submission verified**. Certification approval and +a Store-installed upgrade remain separate checks. Do not rerun the old Store +job or cancel the current certification to manufacture a green workflow. + +## Historical checkpoint after the failed v0.1.02 run PR #19 merged into main at `dbe34f4`; all ten post-merge Verify checks passed. The local Windows RC build, runtime/startup checks and installer packaging passed. diff --git a/tests/test_store_submission.py b/tests/test_store_submission.py index e7af855..7fba6fc 100644 --- a/tests/test_store_submission.py +++ b/tests/test_store_submission.py @@ -317,6 +317,53 @@ def test_initial_preflight_authenticates_with_reads_only(self): self.assertFalse(api.uploads) self.assertEqual(self.report["status"], "PreflightPassed") + def test_blocked_initial_preflight_reports_state_without_mutating(self): + for status in ("Canceled", "Certification", "PendingPublication", "unexpected SECRET"): + api = self.initial_api() + api.pending["status"] = status + before = copy.deepcopy(api.pending) + with self.subTest(status=status), self.assertRaises(store.StoreError) as failure: + self.run_initial(api, preflight_only=True) + self.assertEqual(api.pending, before) + self.assertFalse(api.uploads) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + self.assertEqual(self.report["submissionId"], "200") + self.assertEqual(self.report["observedStoreStatus"], + "Unknown" if status == "unexpected SECRET" else status) + self.assertNotIn("SECRET", str(failure.exception) + json.dumps(self.report)) + + def test_next_tag_reports_current_certification_without_adopting_it(self): + api = self.initial_api() + api.pending["status"] = "Certification" + with self.assertRaisesRegex(store.StoreError, "No published baseline"): + self.run_initial(api, release_tag="v0.1.03", preflight_only=True) + self.assertEqual(self.report["observedStoreStatus"], "Certification") + self.assertFalse(api.uploads) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_failed_live_cli_does_not_leave_a_validated_success_summary(self): + self.create_package() + api = self.initial_api() + api.pending["status"] = "Canceled" + config = self.directory / "initial.json" + config.write_text(json.dumps(self.initial_config())) + report_path = self.directory / "report.json" + summary_path = self.directory / "summary.md" + with patch.object(store, "StoreApi", return_value=api), \ + patch.dict(store.os.environ, {"GITHUB_STEP_SUMMARY": str(summary_path)}), \ + 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), "--report", str(report_path), "--preflight"]): + self.assertEqual(store.main(), 1) + report = json.loads(report_path.read_text()) + self.assertEqual(report["status"], "Failed") + self.assertEqual(report["observedStoreStatus"], "Canceled") + self.assertIn("Status: Failed", summary_path.read_text()) + self.assertIn("required: PendingCommit", summary_path.read_text()) + self.assertNotIn("SECRET", report_path.read_text() + summary_path.read_text()) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + def test_published_preflight_never_creates_a_submission(self): api = FakeApi() self.run_submit(api, preflight_only=True) diff --git a/tools/submit_store_release.py b/tools/submit_store_release.py index 82b91a5..189ee04 100644 --- a/tools/submit_store_release.py +++ b/tools/submit_store_release.py @@ -32,6 +32,13 @@ ACCEPTED = {"PreProcessing", "Certification", "PendingPublication", "Publishing", "Published", "Release"} MARKER_PREFIX = "OpenStudio release automation: " INITIAL_MARKER_PREFIX = "OpenStudio initial draft: " +KNOWN_STATUSES = FAILED | ACCEPTED | {"PendingCommit", "CommitStarted", "None"} + + +def safe_status(submission: dict) -> str: + # Do not echo arbitrary API response strings into logs or Markdown reports. + value = submission.get("status") + return value if isinstance(value, str) and value in KNOWN_STATUSES else "Unknown" class StoreError(RuntimeError): @@ -196,7 +203,8 @@ def prepare_initial_submission(pending: dict, config: dict, package: Path, versi 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.") + raise StoreError(f"The initial draft is not editable (Store status: {safe_status(pending)}; " + "required: PendingCommit); 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.") @@ -317,12 +325,15 @@ def submit(api, package: Path, version: str, sha256: str, notes: str, record, published_id = (app.get("lastPublishedApplicationSubmission") or {}).get("id") pending_id = (app.get("pendingApplicationSubmission") or {}).get("id") initial = not published_id + record(initialSubmission=initial) if not published_id: + pending = api.request("GET", submission_path(pending_id)) if pending_id else None + if pending is not None: + record(submissionId=pending_id, observedStoreStatus=safe_status(pending)) 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 owns(pending, expected): validate_initial_resume(pending, initial_config, package, notes, expected) else: @@ -343,6 +354,7 @@ def submit(api, package: Path, version: str, sha256: str, notes: str, record, validate_initial_resume(pending, initial_config, package, notes, expected) else: published = api.request("GET", submission_path(published_id)) + record(publishedSubmissionId=published_id, observedStoreStatus=safe_status(published)) if owns(published, expected): record(submissionId=published_id, status="Published", alreadySubmitted=True) return @@ -351,6 +363,7 @@ def submit(api, package: Path, version: str, sha256: str, notes: str, record, 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)) + record(submissionId=pending_id, observedStoreStatus=safe_status(pending)) 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: @@ -443,7 +456,7 @@ def record(**values): # Only StoreError is authored/sanitized; parser/file/API internals may # contain untrusted content or secrets. Do not print their raw values. message = str(error) if isinstance(error, StoreError) else "Input or API response validation failed. Check package/report/notes and Partner Center." - record(error=message) + record(status="Failed", error=message) print(message, file=sys.stderr) return 1 finally: @@ -451,6 +464,8 @@ def record(**values): with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: summary.write(f"### Microsoft Store\n\nStatus: {report.get('status', 'Validation failed')}\n\n" f"Submission ID: {report.get('submissionId', 'Not created')}\n\n" + f"Observed Store state: {report.get('observedStoreStatus', 'Not observed')}\n\n" + f"Error: {report.get('error', 'None')}\n\n" "This is submission status, not proof of certification or a Store-delivered upgrade.\n")