瀏覽代碼

Split the Windows installer build so signing can wait for approval

The SignPath Foundation production certificate does not sign on demand
the way the self-signed test certificate does. Every request has to be
approved by hand in the SignPath UI, because the Foundation verifies
what is being signed and which build produced it. The submitting action
waits for that approval with a default timeout of 600 seconds, which is
ample while the test policy approves in seconds and far too short once
the wait is a person noticing a tag went out. A tag pushed at night
would have failed the run ten minutes later with the installer already
compiled and thrown away.

The compile now ends in its own job, which uploads the unsigned artifact
and exposes its id. A second job downloads it, signs it, and does the
release-facing work, with the wait raised to an hour and the job timeout
sized to sit outside it. Separating them is what buys the recovery: the
artifact is uploaded before the wait begins and is addressed by id, so a
missed approval window costs a re-run of the second job alone rather
than a rebuild. Raising the timeout in place would not have given that.

The second job runs for unsigned builds too. Daily prereleases are
deliberately left unsigned to preserve the signing quota, and gating the
whole job on the signing decision would have meant a second copy of the
alias, artifact and release steps for them to run through.

The decision itself moves into a named step that echoes it, so a tag
that came out unsigned can be explained from the run log rather than by
re-reading the expression. It is one source of truth feeding both jobs,
which a job-level env could not be.

Every step body is otherwise unchanged. The property worth keeping is
that none of the alias, upload and release-attach steps carry always(),
so GitHub skips all three when signing fails or times out and an
unsigned .exe cannot reach a release; that is now written next to them,
because it is easy to break by adding a condition without noticing.

The policy slug stays at test-signing and the signature check stays
lenient -- the test certificate is self-signed and reports UnknownError,
so requiring Valid would fail every run until the production certificate
is imported. Both are the cutover. The restructure behaves identically
under the test policy, the request simply completing at once instead of
waiting, so it can be proven green beforehand.
maziggy 2 周之前
父節點
當前提交
7f8d79fe50
共有 2 個文件被更改,包括 73 次插入10 次删除
  1. 72 10
      .github/workflows/windows-installer.yml
  2. 1 0
      CHANGELOG.md

+ 72 - 10
.github/workflows/windows-installer.yml

@@ -10,6 +10,18 @@ name: Windows Installer
 # program. Daily prereleases are deliberately left unsigned so they don't burn
 # the OSS signing quota; use the `sign` dispatch input to exercise the signing
 # path by hand.
+#
+# The work is split across two jobs on purpose. Signing requests against the
+# Foundation *production* certificate require a human to approve them in the
+# SignPath UI, so the submit step can sit waiting for up to an hour. Keeping
+# the compile in its own job means that wait doesn't hold the build hostage:
+# the unsigned artifact is already uploaded and addressable by id, so a missed
+# approval window is recovered by re-running `publish` alone rather than
+# rebuilding the installer from scratch.
+#
+# Note that `publish` runs for unsigned builds too -- it is the single place
+# that produces the release-facing artifact, so the daily-prerelease path and
+# the signed-tag path share one set of alias/upload/attach steps.
 
 on:
   push:
@@ -28,9 +40,9 @@ on:
 # that step and could run with read-only, but a single workflow-level
 # block keeps the surface auditable in one place.
 # actions: read lets the SignPath connector download the uploaded artifact
-# through the API. Declaring a permissions block at all drops every scope we
-# don't name to `none`, so the signing step fails to fetch the artifact
-# without it.
+# through the API (and lets download-artifact do the same in `publish`). Declaring
+# a permissions block at all drops every scope we don't name to `none`, so the
+# signing step fails to fetch the artifact without it.
 permissions:
   contents: write
   actions: read
@@ -40,17 +52,35 @@ jobs:
     runs-on: windows-latest
     timeout-minutes: 30
 
-    env:
-      # Sign real release tags but not `-daily.` prereleases, and let a manual
-      # run opt in. GitHub's `||` returns the *last* operand when everything is
-      # falsy (an empty string here, not `false`), so every use site compares
-      # against the string 'true' rather than treating this as a boolean.
-      SIGN: ${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}
+    outputs:
+      # The artifact id is how SignPath addresses the thing to sign; see the
+      # upload step below for why that indirection exists.
+      artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
+      sign: ${{ steps.decide.outputs.sign }}
 
     steps:
       - name: Checkout
         uses: actions/checkout@v4
 
+      # Sign real release tags but not `-daily.` prereleases, and let a manual
+      # run opt in. GitHub's `||` returns the *last* operand when everything is
+      # falsy (an empty string here, not `false`), so this normalises the answer
+      # to the literal strings 'true'/'false' once, in one place, and every use
+      # site compares against 'true' rather than treating it as a boolean.
+      # Echoing the decision makes "why wasn't my tag signed?" answerable from
+      # the run log alone.
+      - name: Decide whether this build gets signed
+        id: decide
+        shell: bash
+        run: |
+          if [ "${{ (startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')) || inputs.sign }}" = "true" ]; then
+            echo "sign=true" >> "$GITHUB_OUTPUT"
+            echo "signing ENABLED for $GITHUB_REF"
+          else
+            echo "sign=false" >> "$GITHUB_OUTPUT"
+            echo "signing SKIPPED for $GITHUB_REF"
+          fi
+
       - name: Setup Python
         uses: actions/setup-python@v5
         with:
@@ -89,11 +119,36 @@ jobs:
           path: installers/windows/build/output/*.exe
           if-no-files-found: error
 
+  publish:
+    needs: build
+    runs-on: windows-latest
+    # Sized to sit outside the signing wait below: an hour for a human to
+    # approve the request in the SignPath UI, plus headroom for the download,
+    # verification and upload either side of it.
+    timeout-minutes: 70
+
+    env:
+      SIGN: ${{ needs.build.outputs.sign }}
+
+    steps:
+      # Rehydrate the compiled installer into the same path the build job used,
+      # so every step below is identical whether or not signing ran. No checkout
+      # is needed here -- nothing in this job reads the repository.
+      - name: Download unsigned installer
+        uses: actions/download-artifact@v7
+        with:
+          name: bambuddy-windows-installer-unsigned
+          path: installers/windows/build/output
+
       # The artifact arrives at SignPath as a .zip (that is simply what
       # upload-artifact produces), so the artifact configuration on the SignPath
       # side describes a <zip-file> wrapping the <pe-file>. With skip-decompress
       # left at its default the signed archive is extracted again here, so
       # `signed/` ends up holding the bare .exe.
+      #
+      # The wait timeout is explicit because the action defaults to 600s, which
+      # is fine for the auto-approved test policy but far too short once a human
+      # has to approve each production request by hand.
       - name: Sign installer (SignPath)
         if: env.SIGN == 'true'
         uses: signpath/github-action-submit-signing-request@v2
@@ -104,8 +159,9 @@ jobs:
           organization-id: '4d7e5b59-d0fb-4a6b-b385-b861e18c6386'
           project-slug: 'bambuddy'
           signing-policy-slug: 'test-signing'
-          github-artifact-id: ${{ steps.upload_unsigned.outputs.artifact-id }}
+          github-artifact-id: ${{ needs.build.outputs.artifact-id }}
           wait-for-completion: true
+          wait-for-completion-timeout-in-seconds: '3600'
           output-artifact-directory: installers/windows/build/signed
 
       # Replace the unsigned binary in-place so every downstream step (alias,
@@ -157,6 +213,12 @@ jobs:
       # anyway (`latest` skips prereleases), so the alias adds no value there.
       #
       # Runs after signing so the alias is a copy of the *signed* binary.
+      #
+      # This and the two steps after it carry no `always()`/`failure()`, so
+      # GitHub ANDs an implicit `success()` into each. A failed or timed-out
+      # signing therefore skips the alias, the artifact upload and the release
+      # attachment -- an unsigned .exe cannot reach a release. Preserve that
+      # property through any future edit.
       - name: Create unversioned alias (non-daily tags only)
         if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-daily.')
         shell: pwsh

+ 1 - 0
CHANGELOG.md

@@ -11,6 +11,7 @@ All notable changes to Bambuddy will be documented in this file.
 
 ### Changed
 - **The Watchtower we recommend for daily builds is the maintained fork (#2917, reported by @CamelT0E)** — The daily-build instructions in the README, on Docker Hub and in every daily prerelease pointed at containrrr.dev/watchtower. That project has been archived and read-only since December 2025 and its last release, v1.7.1, is from November 2023, so anyone following the recommendation was being handed a container with Docker socket access that had not received a fix in over two years. Development continues in Nicholas Fedor's fork, which ships as `nickfedor/watchtower` and released v1.21.0 this month. All four references now point at watchtower.nickfedor.com and name the image, including the release-notes template in `docker-publish-daily-beta.sh` that produced the screenshot in the report — the READMEs alone would have left every future daily prerelease repeating the dead link. Existing images keep working; only the recommendation changed.
+- **The Windows installer build is split in two so a signing request can wait for a human (SignPath Foundation)** — Release tags are Authenticode-signed through the SignPath Foundation OSS programme, and the production certificate does not sign on demand the way the self-signed test certificate does: every request has to be approved by hand in the SignPath UI, because the Foundation verifies what is being signed and which build it came from. The submitting action waits for that approval with a default timeout of 600 seconds, which is ample when the test policy approves automatically in seconds and far too short once the wait is a person noticing a tag went out. A tag pushed at night would have failed the run ten minutes later with the installer already compiled and thrown away. The compile now ends in its own job that uploads the unsigned artifact and stops; a second job downloads it, signs it, and does the release-facing work, with the wait raised to an hour. Because the artifact is uploaded before the wait begins and is addressed by id, a missed approval window is recovered by re-running the second job alone rather than rebuilding the installer — which is the reason to separate them rather than simply raise the timeout in place. The second job runs for unsigned builds too, so the daily prereleases that are deliberately left unsigned to preserve the signing quota keep going out through exactly one set of alias, artifact and release steps. The property that matters is unchanged and now recorded next to the steps that depend on it: none of the alias, upload or release-attach steps carry `always()`, so GitHub skips all three when signing fails or times out, and an unsigned `.exe` cannot reach a release. Nothing about the signed output changes, and the restructure behaves identically under the test policy — the request simply completes immediately instead of waiting — so it can be proven green before the production certificate arrives.
 
 ### Fixed
 - **The bundled chamber-preheat table was unreadable to the code that reads it** — every lookup of the per-filament chamber map happens after the keys are upper-cased, but an install that had never opened the setting got the bundled table back exactly as declared, with its lowercase `default` row. The scheduler then looked for `DEFAULT`, found nothing, and used a hardcoded 0 for any filament without a row of its own. It reported the right number only because that bundled default is 0 — raising it would have silently changed nothing for everyone who had not customised the map. Both paths out of the parser now honour the one contract it documents.