diff --git a/.github/workflows/desktop-bundled-release.yml b/.github/workflows/desktop-bundled-release.yml index b4d108c096..a8a303dca3 100644 --- a/.github/workflows/desktop-bundled-release.yml +++ b/.github/workflows/desktop-bundled-release.yml @@ -497,6 +497,30 @@ jobs: payload-signatures-v1-${{ runner.os }}-${{ matrix.target.label }}- payload-signatures-v1-${{ runner.os }}- + # Downloadable artifacts (commit builds, candidates, published tags, + # channels) must be Authenticode-signed. Without the Azure Trusted + # Signing vars the build only warns and ships UNSIGNED binaries, which + # is right for a fork or a local build and wrong for a release. + - name: Require Azure signing when publishing + if: inputs.upload_release == true || inputs.release-phase == 'candidate' || inputs.build_commit != '' || inputs.channel != '' + shell: bash + env: + AZURE_SIGN_ENDPOINT: ${{ vars.AZURE_SIGN_ENDPOINT }} + AZURE_SIGN_ACCOUNT: ${{ vars.AZURE_SIGN_ACCOUNT }} + AZURE_SIGN_PROFILE: ${{ vars.AZURE_SIGN_PROFILE }} + AZURE_SIGN_PUBLISHER: ${{ vars.AZURE_SIGN_PUBLISHER }} + AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }} + AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }} + run: | + missing=() + for name in AZURE_SIGN_ENDPOINT AZURE_SIGN_ACCOUNT AZURE_SIGN_PROFILE AZURE_SIGN_PUBLISHER AZURE_CLIENT_ID AZURE_TENANT_ID; do + [ -z "${!name}" ] && missing+=("$name") + done + if [ ${#missing[@]} -gt 0 ]; then + echo "::error::required Azure Trusted Signing vars are missing from release-signing: ${missing[*]}" + exit 1 + fi + - name: Azure login (OIDC) if: vars.AZURE_CLIENT_ID != '' uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 diff --git a/tests/ci/test_desktop_release_tag_admission.py b/tests/ci/test_desktop_release_tag_admission.py index 508f1f8362..e0ccc1af7d 100644 --- a/tests/ci/test_desktop_release_tag_admission.py +++ b/tests/ci/test_desktop_release_tag_admission.py @@ -223,3 +223,34 @@ def test_malformed_tag_is_refused_before_any_git_work(tmp_path: Path): "not a release tag" in proc.stdout + proc.stderr or "does not match pyproject.toml version" in proc.stdout + proc.stderr ) + + +def _require_step(job: str, name_prefix: str) -> dict: + steps = _workflow()["jobs"][job]["steps"] + (step,) = [s for s in steps if isinstance(s, dict) and s.get("name", "").startswith(name_prefix)] + return step + + +@pytest.mark.skipif(shutil.which("bash") is None, reason="needs bash") +def test_downloadable_windows_builds_refuse_to_ship_unsigned(tmp_path: Path): + """The Windows signer only warns without AZURE_SIGN_*; every lane whose + artifacts are downloadable must therefore fail before building, under + the same gate the macOS leg uses for its signing credentials.""" + step = _require_step("build-win32-release", "Require Azure signing") + assert step["if"] == _require_step("build-darwin-release", "Require signing credentials")["if"] + assert "build-commit" not in step["if"] and "release-phase == 'candidate'" in step["if"] + names = list(step["env"]) + assert {"AZURE_SIGN_ENDPOINT", "AZURE_SIGN_ACCOUNT", "AZURE_SIGN_PROFILE", "AZURE_CLIENT_ID"} <= set(names) + + def run(**values: str) -> subprocess.CompletedProcess: + env = {"PATH": os.environ["PATH"], "RUNNER_TEMP": str(tmp_path)} + env.update({name: "" for name in names}) + env.update(values) + return subprocess.run(["bash", "-euo", "pipefail", "-c", step["run"]], env=env, + capture_output=True, text=True, timeout=60) + + proc = run() + assert proc.returncode != 0 and "::error::" in proc.stdout and "AZURE_SIGN_ENDPOINT" in proc.stdout + assert run(**{name: "x" for name in names}).returncode == 0 + partial = run(**{name: "x" for name in names if name != "AZURE_CLIENT_ID"}) + assert partial.returncode != 0 and "AZURE_CLIENT_ID" in partial.stdout