From 4b64c74bfdd460f56c3e3c256e2fc7f89be0abea Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 01:08:06 +0200 Subject: [PATCH 1/2] ci(store): add a retry path for Store submission that rebuilds nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.yml's publish-msstore job has no usable retry, which v1.9.5 found the hard way. Re-running the failed job replays the workflow definition frozen into the original run, so the fix landed afterwards is not picked up. Re-dispatching build.yml rebuilds all five platforms and re-uploads the release assets with `--clobber` — rewriting a published release to correct a Store submission — and dispatching it from main rather than the tag would rewrite it with binaries built from code that release never contained. So: a workflow_dispatch that takes the appx build already produced and submits it. No rebuild, no release asset touched, and the macOS legs that needed three attempts are not in the path. `dry_run` passes --noCommit, which leaves the submission in draft. That is the validation path the review of #379 asked for and build.yml still lacks: without it, "let's see whether the .appx is accepted" puts a build into certification. It also answers the open question from that PR cheaply, since --inputFile is documented for .msix/.msixupload and we produce .appx. Read-only token, persist-credentials off, and the tag checked out rather than the default branch so the project state matches the package. Verified as far as it can be without running: YAML parses, all five bash steps pass `bash -n`, and the pwsh block parses through Parser::ParseFile — which caught two real defects. `$args` is a PowerShell automatic variable, and an em dash inside a double-quoted string terminated it early under a non-UTF-8 read, orphaning the rest of the message. --- .github/workflows/publish-msstore.yml | 195 ++++++++++++++++++ .../engineering/release-and-secrets.md | 8 +- 2 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/publish-msstore.yml diff --git a/.github/workflows/publish-msstore.yml b/.github/workflows/publish-msstore.yml new file mode 100644 index 00000000..a92eb876 --- /dev/null +++ b/.github/workflows/publish-msstore.yml @@ -0,0 +1,195 @@ +name: Publish to Microsoft Store (retry) + +# Submits an already-built appx to the Store, without rebuilding anything. +# +# build.yml's own publish-msstore job is the normal path. This exists because +# that job has no usable retry: re-running it replays the workflow definition +# frozen into the original run, so a fix landed afterwards is not picked up, and +# re-dispatching build.yml rebuilds every platform and re-uploads the release +# assets with `--clobber` — rewriting a published release to correct a Store +# submission. v1.9.5 hit exactly that dead end. +# +# So this takes the appx that build already produced and submits it. Nothing is +# rebuilt, no release asset is touched, and the flaky macOS legs are not in the +# way. + +on: + workflow_dispatch: + inputs: + release_tag: + description: "Stable tag whose appx should be submitted (e.g. v1.9.5)" + required: true + type: string + run_id: + description: "Build run to take the appx from. Leave empty to use the most recent build for the tag." + required: false + type: string + dry_run: + description: "Create the submission but leave it in draft (--noCommit). Use this to test without shipping." + required: false + type: boolean + default: false + +# Read-only: this checks the tree out so the CLI can identify the project, and +# reads a build artifact. Partner Center is reached with its own Entra +# credentials, not with this token. +permissions: + contents: read + actions: read + +concurrency: + group: publish-msstore-${{ inputs.release_tag }} + cancel-in-progress: false + +jobs: + submit: + name: Submit ${{ inputs.release_tag }} to the Store + runs-on: windows-latest + # Same gate as build.yml: an RC reaching the Store would go through + # certification and land on every user's machine as an automatic update. + if: ${{ vars.MSSTORE_PRODUCT_ID != '' }} + steps: + - name: Validate the tag + id: tag + shell: bash + env: + TAG: ${{ inputs.release_tag }} + run: | + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Expected a stable tag like v1.9.5; got '${TAG}'. RCs must never reach the Store." + exit 1 + fi + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + # All-or-nothing, as in build.yml: a half-configured publisher is a + # misnamed secret, and failing loudly beats submitting nothing quietly. + - name: Resolve Store credentials + id: store + shell: bash + env: + AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} + AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + SELLER_ID: ${{ secrets.SELLER_ID }} + run: | + required=(AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID + AZURE_AD_APPLICATION_SECRET SELLER_ID) + missing=() + for name in "${required[@]}"; do + [[ -n "${!name}" ]] || missing+=("$name") + done + if [[ ${#missing[@]} -ne 0 ]]; then + echo "::error::Store credentials incomplete; missing: ${missing[*]}" + exit 1 + fi + + - name: Check out the tag + uses: actions/checkout@v7 + with: + # `msstore publish` takes a project root and detects the app type + # there; it is not given a package to introspect. Checking out the tag + # rather than the default branch keeps that project state matching the + # appx being submitted. + ref: ${{ steps.tag.outputs.tag }} + # Nothing here pushes, and the token would otherwise sit in .git/config + # for the third-party CLI action below to read. + persist-credentials: false + + - name: Resolve the build run + id: run + shell: bash + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.tag.outputs.tag }} + RUN_ID: ${{ inputs.run_id }} + run: | + if [[ -n "$RUN_ID" ]]; then + echo "Using the run id given: $RUN_ID" + else + # Deliberately not filtered on conclusion: the run this is most + # likely to be retrying is the one whose Store step failed, so + # requiring success would skip exactly the build we want. + RUN_ID="$(gh run list --workflow build.yml --branch "$TAG" \ + --limit 1 --json databaseId --jq '.[0].databaseId')" + if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then + echo "::error::No build.yml run found for ${TAG}. Pass run_id explicitly." + exit 1 + fi + echo "Resolved the most recent build for ${TAG}: $RUN_ID" + fi + echo "id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Download the Store package + shell: bash + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ steps.run.outputs.id }} + run: | + mkdir -p artifacts/store + gh run download "$RUN_ID" --name openscreen-windows-store --dir artifacts/store + + - name: Configure Microsoft Store CLI + uses: microsoft/microsoft-store-apppublisher@v1.1 + + - name: Submit the package to the Store + id: submit + shell: pwsh + env: + PRODUCT_ID: ${{ vars.MSSTORE_PRODUCT_ID }} + TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} + SELLER_ID: ${{ secrets.SELLER_ID }} + CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + # Through env rather than `${{ }}` expanded straight into shell source. + msstore reconfigure ` + --tenantId $env:TENANT_ID ` + --sellerId $env:SELLER_ID ` + --clientId $env:CLIENT_ID ` + --clientSecret $env:CLIENT_SECRET + + $packages = @(Get-ChildItem artifacts/store -Recurse -Include '*.appx','*.msix','*.msixupload') + if ($packages.Count -eq 0) { throw 'no package in the downloaded artifact' } + if ($packages.Count -ne 1) { + throw "expected one package, found $($packages.Count): refusing to guess which to submit" + } + $pkg = $packages[0] + + # The positional argument is the project root, NOT the package: passing + # the package there is what failed v1.9.5 ("could not find a project + # publisher"). The package goes through --inputFile. + # Not $args: that is a PowerShell automatic variable. + $cmdArgs = @('publish', '.', '--inputFile', $pkg.FullName, '--appId', $env:PRODUCT_ID) + if ($env:DRY_RUN -eq 'true') { + # Leaves the submission in draft instead of sending it to + # certification: the only way to test this path without shipping. + $cmdArgs += '--noCommit' + Write-Output "DRY RUN: submitting $($pkg.Name) as a draft only" + } else { + Write-Output "Submitting $($pkg.Name) to product $env:PRODUCT_ID" + } + msstore @cmdArgs + + # Report what happened, not what was configured — the mistake that let + # v1.9.5's failed submission read as a success (see 1617c930). + - name: Summary + if: always() + shell: bash + env: + SUBMIT: ${{ steps.submit.outcome }} + TAG: ${{ inputs.release_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + case "$SUBMIT" in + success) + if [[ "$DRY_RUN" == "true" ]]; then + echo "Draft submission created for ${TAG}; nothing was sent to certification." >> "$GITHUB_STEP_SUMMARY" + else + echo "Submitted ${TAG} to the Store. Certification still has to pass before it goes live." >> "$GITHUB_STEP_SUMMARY" + fi + ;; + *) + echo "Store submission for ${TAG} did NOT happen (submit step: ${SUBMIT:-did not run}). The appx is unchanged; upload it by hand if this keeps failing." >> "$GITHUB_STEP_SUMMARY" + ;; + esac diff --git a/technical-documentation/engineering/release-and-secrets.md b/technical-documentation/engineering/release-and-secrets.md index 6cdfb8a3..169d610e 100644 --- a/technical-documentation/engineering/release-and-secrets.md +++ b/technical-documentation/engineering/release-and-secrets.md @@ -175,7 +175,13 @@ That failure was visible only because the same release carried the fix that repo **Still unverified, and the next thing likely to break:** `--inputFile` is documented for `.msix` and `.msixupload`, and `build:win:store` produces an `.appx` (`electron-builder --win appx`). Whether the CLI accepts that extension is untested. -**There is no dry run, so do not reach for one.** The job is gated to stable tags, so the only ways to exercise it are a real release or a `workflow_dispatch` of `build.yml` with a stable `release_tag` — and neither is a rehearsal. `msstore publish` commits the submission unless it is given `-nc, --noCommit`, which this job does not pass, so a dispatch fired "just to see whether the `.appx` is accepted" creates a submission that enters certification and reaches users. Adding `--noCommit` behind a dispatch input is what a real validation path would need; until someone builds that, assume the Store needs the manual upload below, and treat the next stable release as the test. +### Retrying a Store submission + +`publish-msstore.yml` submits an already-built appx on demand: `workflow_dispatch` with a stable `release_tag`, optionally a `run_id` (defaults to the most recent `build.yml` run for that tag), and a **`dry_run`** flag. + +It exists because `build.yml`'s own job has no usable retry. Re-running the failed job replays the workflow definition frozen into the original run, so a fix landed afterwards is never picked up; and re-dispatching `build.yml` rebuilds every platform and re-uploads the release assets with `--clobber`, rewriting a published release to correct a Store submission — and, if dispatched from `main` rather than the tag, rewriting it with binaries built from code that release never contained. v1.9.5 hit both walls. + +**`dry_run: true` is the only safe way to test this path.** It passes `-nc, --noCommit`, which creates the submission and leaves it in draft instead of sending it to certification. Without it — and this is what `build.yml` does — `msstore publish` commits, so a dispatch fired "just to see whether the `.appx` is accepted" puts a build in front of users. Validate with the dry run first; submit for real only once it comes back clean. Rotate by issuing a new client secret on the Entra registration, updating `AZURE_AD_APPLICATION_SECRET`, publishing one release to confirm, then deleting the old secret. The tenant, client and seller IDs change only when the registration or account does. From 5e27f0569e2e7753468303c145eb466f2da97399 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Sat, 15 Aug 2026 01:18:55 +0200 Subject: [PATCH 2/2] ci(store): fix an empty expression, a silent skip, and an unchecked run id Three review findings, one of which would have stopped the workflow from running at all. `${{ }}` inside a `run:` block is not a comment. Actions substitutes expressions across the whole block before the shell sees it, and an empty one is a parse error. build.yml has the same text at line 1072 and is fine, because there it sits in a YAML comment that never reaches the expression parser -- the distinction is which side of `run:` it falls on. My local YAML and shell checks could not see this: it is neither. The job-level `if: vars.MSSTORE_PRODUCT_ID != ''` skipped the whole job when unconfigured, and a skipped job is green and silent. build.yml can afford that as one job in an automatic release; this one exists to be triggered by hand, where "nothing happened, no error" is the worst answer. MSSTORE_PRODUCT_ID moves into the configuration check and fails loudly with a Summary line. And an explicitly supplied run_id was trusted as given. Nothing downstream inspects what is inside the artifact, so a transposed digit would submit another commit's package to the Store under this tag. It is now checked to be a build.yml run whose head_sha matches the tag being published -- verified against the real v1.9.5 run first, so the check accepts the run it exists to retry rather than rejecting it. Not taken: "afterwards" -> "afterward". The repo uses "afterwards" throughout (AGENTS.md, build-and-packaging.md, release-and-secrets.md, manual-e2e-checklist.md, website/docs); changing one instance would make it the odd one out. --- .github/workflows/publish-msstore.yml | 48 ++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish-msstore.yml b/.github/workflows/publish-msstore.yml index a92eb876..36b6917f 100644 --- a/.github/workflows/publish-msstore.yml +++ b/.github/workflows/publish-msstore.yml @@ -45,9 +45,12 @@ jobs: submit: name: Submit ${{ inputs.release_tag }} to the Store runs-on: windows-latest - # Same gate as build.yml: an RC reaching the Store would go through - # certification and land on every user's machine as an automatic update. - if: ${{ vars.MSSTORE_PRODUCT_ID != '' }} + # No job-level `if` on MSSTORE_PRODUCT_ID, deliberately. build.yml can afford + # to skip: it is one job among many in an automatic release. This one is + # something a person asked for by hand, and a skipped job is green and + # silent — the exact shape that let Homebrew and WinGet report success while + # publishing nothing for eight releases. Missing configuration is checked + # below and fails loudly instead. steps: - name: Validate the tag id: tag @@ -63,23 +66,28 @@ jobs: # All-or-nothing, as in build.yml: a half-configured publisher is a # misnamed secret, and failing loudly beats submitting nothing quietly. - - name: Resolve Store credentials + - name: Resolve Store configuration id: store shell: bash env: + MSSTORE_PRODUCT_ID: ${{ vars.MSSTORE_PRODUCT_ID }} AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }} AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }} AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} SELLER_ID: ${{ secrets.SELLER_ID }} run: | - required=(AZURE_AD_TENANT_ID AZURE_AD_APPLICATION_CLIENT_ID - AZURE_AD_APPLICATION_SECRET SELLER_ID) + # MSSTORE_PRODUCT_ID is in here rather than in a job-level `if` so an + # unconfigured repository gets an error and a Summary line, not a + # silent skip on a run somebody triggered on purpose. + required=(MSSTORE_PRODUCT_ID AZURE_AD_TENANT_ID + AZURE_AD_APPLICATION_CLIENT_ID AZURE_AD_APPLICATION_SECRET + SELLER_ID) missing=() for name in "${required[@]}"; do [[ -n "${!name}" ]] || missing+=("$name") done if [[ ${#missing[@]} -ne 0 ]]; then - echo "::error::Store credentials incomplete; missing: ${missing[*]}" + echo "::error::Store configuration incomplete; missing: ${missing[*]}" exit 1 fi @@ -104,7 +112,28 @@ jobs: RUN_ID: ${{ inputs.run_id }} run: | if [[ -n "$RUN_ID" ]]; then - echo "Using the run id given: $RUN_ID" + # A hand-typed run id is the one input that can quietly ship the + # wrong bytes: nothing downstream re-checks what is inside the + # artifact, so a transposed digit could submit another commit's + # package to the Store under this tag. Confirm it is a build.yml run + # and that it was built from the tag being published. + INFO="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}" \ + --jq '{path: .path, sha: .head_sha}' 2>/dev/null)" || { + echo "::error::Run ${RUN_ID} not found in ${GITHUB_REPOSITORY}." + exit 1 + } + RUN_PATH="$(jq -r .path <<<"$INFO")" + RUN_SHA="$(jq -r .sha <<<"$INFO")" + TAG_SHA="$(git rev-parse HEAD)" + if [[ "$RUN_PATH" != ".github/workflows/build.yml" ]]; then + echo "::error::Run ${RUN_ID} is ${RUN_PATH}, not build.yml." + exit 1 + fi + if [[ "$RUN_SHA" != "$TAG_SHA" ]]; then + echo "::error::Run ${RUN_ID} built ${RUN_SHA}, but ${TAG} is ${TAG_SHA}." + exit 1 + fi + echo "Using run ${RUN_ID}: build.yml at ${RUN_SHA}" else # Deliberately not filtered on conclusion: the run this is most # likely to be retrying is the one whose Store step failed, so @@ -142,7 +171,8 @@ jobs: CLIENT_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }} DRY_RUN: ${{ inputs.dry_run }} run: | - # Through env rather than `${{ }}` expanded straight into shell source. + # Secrets arrive through env, not through expression interpolation + # expanded straight into shell source. msstore reconfigure ` --tenantId $env:TENANT_ID ` --sellerId $env:SELLER_ID `