Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 17 additions & 6 deletions docs/release-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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/<version>.md` on the final source, then push the stable
version tag on that merged `main` revision.
Expand All @@ -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
Expand Down
45 changes: 44 additions & 1 deletion docs/store-release-activation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
47 changes: 47 additions & 0 deletions tests/test_store_submission.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 18 additions & 3 deletions tools/submit_store_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.")
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -443,14 +456,16 @@ 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:
if os.environ.get("GITHUB_STEP_SUMMARY") and not args.print_package_version:
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")


Expand Down
Loading