371 lines
14 KiB
Python
371 lines
14 KiB
Python
#!/usr/bin/env python3
|
|
"""Guard the generated MSIX manifest behind Microsoft Store submissions.
|
|
|
|
windows/build-msix.ps1 generates AppxManifest.xml per architecture at package
|
|
time, so there is no manifest in the tree to review. These checks read the
|
|
script as text and validate the template it returns - they never invoke
|
|
PowerShell, because root CI runs on Linux where pwsh cannot be assumed, and
|
|
-EmitManifestOnly is the Windows-side developer loop instead.
|
|
|
|
The invariants pinned here are the ones makeappx, the shell and Store
|
|
certification enforce: the schema's fixed child order, identity strings that
|
|
satisfy their pattern constraints and match the reserved product, a four-part
|
|
version whose revision field is the 0 the Store reserves, one template shared by
|
|
both architectures, assets that actually exist in the tree, and the resource
|
|
index without which the unplated taskbar icons never resolve.
|
|
"""
|
|
|
|
import hashlib
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
from xml.etree import ElementTree
|
|
|
|
from pubspec_version import parse_pubspec_version
|
|
from workflow_yaml import job_block
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
DEFAULT_SCRIPT = ROOT / "windows/build-msix.ps1"
|
|
if len(sys.argv) > 2:
|
|
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [build-msix-path]")
|
|
SCRIPT = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_SCRIPT
|
|
ASSETS = ROOT / "windows/msix/assets"
|
|
CMAKE = ROOT / "windows/CMakeLists.txt"
|
|
PUBSPEC = ROOT / "pubspec.yaml"
|
|
WORKFLOW = ROOT / ".github/workflows/build.yml"
|
|
MSIX_STEP = "Build Store package (MSIX)"
|
|
BUNDLE = "plezy-windows.msixbundle"
|
|
# Partner Center reserves these identity values and derives the package family name.
|
|
IDENTITY_NAME = "edde746.Plezy"
|
|
PUBLISHER = "CN=AA9C53CB-AD3C-48DA-B3E3-D1E8986D4E25"
|
|
PUBLISHER_DISPLAY_NAME = "edde746"
|
|
PACKAGE_FAMILY_SUFFIX = "13q3sv6jzathm"
|
|
FOUNDATION = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
|
ASSET_REFERENCE = re.compile(r"assets\\([A-Za-z0-9._-]+\.png)")
|
|
# Package child order is fixed by the foundation schema.
|
|
SCHEMA_ORDER = (
|
|
"Identity",
|
|
"PhoneIdentity",
|
|
"PublisherInfo",
|
|
"Properties",
|
|
"Resources",
|
|
"Dependencies",
|
|
"Capabilities",
|
|
"Applications",
|
|
"Extensions",
|
|
)
|
|
REQUIRED_ELEMENTS = (
|
|
"Identity",
|
|
"Properties",
|
|
"Resources",
|
|
"Dependencies",
|
|
"Capabilities",
|
|
"Applications",
|
|
)
|
|
REQUIRED_CAPABILITIES = ("runFullTrust", "internetClient", "privateNetworkClientServer")
|
|
# Certification requires these capabilities; optional assets are checked only when referenced.
|
|
REQUIRED_ASSETS = ("StoreLogo.png", "Square150x150Logo.png", "Square44x44Logo.png")
|
|
|
|
# Normalize line endings so subsequent patterns are portable.
|
|
text = SCRIPT.read_text(encoding="utf-8").replace("\r\n", "\n")
|
|
errors: list[str] = []
|
|
|
|
|
|
def require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
errors.append(message)
|
|
|
|
|
|
def local_name(tag: str) -> str:
|
|
return tag.rpartition("}")[2]
|
|
|
|
|
|
def declarations() -> str:
|
|
"""New-AppxManifest above its here-string: params and identity strings."""
|
|
match = re.search(r'(?ms)^function New-AppxManifest \{\n(.*?)^ return @"$', text)
|
|
require(match is not None, "New-AppxManifest must return a here-string template")
|
|
return match.group(1) if match else ""
|
|
|
|
|
|
def template() -> str:
|
|
"""The AppxManifest.xml emitted by New-AppxManifest."""
|
|
match = re.search(r'(?ms)^ return @"\n(.*?)\n"@\n', text)
|
|
require(match is not None, "New-AppxManifest must return a single here-string template")
|
|
return match.group(1) if match else ""
|
|
|
|
|
|
def substitute(manifest: str, values: dict[str, str]) -> str:
|
|
"""Fill the template's PowerShell interpolations with their real values.
|
|
|
|
Longest name first: $PublisherDisplayName also starts with $Publisher, and
|
|
PowerShell resolves the longer name.
|
|
"""
|
|
for name in sorted(values, key=len, reverse=True):
|
|
manifest = manifest.replace(f"${name}", values[name])
|
|
unresolved = sorted(set(re.findall(r"\$\w+", manifest)))
|
|
require(
|
|
not unresolved,
|
|
"the template interpolates values this checker cannot resolve: "
|
|
f"{', '.join(unresolved)}",
|
|
)
|
|
return manifest
|
|
|
|
|
|
def package_family_suffix(publisher: str) -> str:
|
|
"""The 13-character hash Windows derives from a publisher distinguished name.
|
|
|
|
SHA-256 over the UTF-16LE publisher, first 8 bytes, padded to 65 bits and
|
|
base32-encoded with the alphabet Windows uses for this (i, l, o and u are
|
|
omitted).
|
|
"""
|
|
digest = hashlib.sha256(publisher.encode("utf-16-le")).digest()[:8]
|
|
bits = f"{int.from_bytes(digest, 'big'):064b}0"
|
|
alphabet = "0123456789abcdefghjkmnpqrstvwxyz"
|
|
return "".join(alphabet[int(bits[index : index + 5], 2)] for index in range(0, 65, 5))
|
|
|
|
|
|
def named_step(block: str, name: str) -> str:
|
|
match = re.search(rf"(?ms)^ - name: {re.escape(name)}\n.*?(?=^ - |\Z)", block)
|
|
require(match is not None, f"missing '{name}' step in package-windows")
|
|
return match.group(0) if match else ""
|
|
|
|
|
|
require(
|
|
"function New-AppxManifest" in text,
|
|
"the manifest must be built by New-AppxManifest so every architecture shares one template",
|
|
)
|
|
prelude = declarations()
|
|
manifest_template = template()
|
|
|
|
# A single manifest template prevents architecture copies from drifting.
|
|
for once in (
|
|
r'^ return @"$',
|
|
r"^<Package ",
|
|
r"^ <Identity ",
|
|
r"^ <Capabilities>$",
|
|
r"^ <Applications>$",
|
|
):
|
|
require(
|
|
len(re.findall(once, text, re.MULTILINE)) == 1,
|
|
f"{once} must match exactly one line; a second copy of the template will drift",
|
|
)
|
|
|
|
# Caller parameters and declared identity values must cover every interpolation.
|
|
for parameter in ("MsixVersion", "Architecture"):
|
|
require(
|
|
f"[Parameter(Mandatory)][string]${parameter}" in prelude,
|
|
f"New-AppxManifest must take ${parameter} as a mandatory parameter",
|
|
)
|
|
pubspec_version, _ = parse_pubspec_version(PUBSPEC.read_text(encoding="utf-8"))
|
|
values = {"MsixVersion": f"{pubspec_version}.0", "Architecture": "x64"}
|
|
values.update(re.findall(r'(?m)^ \$(\w+) = "([^"]*)"$', prelude))
|
|
|
|
substituted = substitute(manifest_template, values)
|
|
package = None
|
|
try:
|
|
package = ElementTree.fromstring(substituted)
|
|
except ElementTree.ParseError as error:
|
|
require(False, f"the substituted manifest must be well-formed XML: {error}")
|
|
|
|
if package is not None:
|
|
require(
|
|
package.tag == f"{{{FOUNDATION}}}Package",
|
|
"the root element must be Package in the appx foundation namespace",
|
|
)
|
|
|
|
present = [local_name(child.tag) for child in package]
|
|
missing = [element for element in REQUIRED_ELEMENTS if element not in present]
|
|
require(not missing, f"the manifest must declare {', '.join(missing)}")
|
|
require(
|
|
[element for element in present if element in SCHEMA_ORDER]
|
|
== [element for element in SCHEMA_ORDER if element in present],
|
|
"Package children must keep schema order "
|
|
f"({', '.join(SCHEMA_ORDER)}); makeappx rejects a reordered manifest",
|
|
)
|
|
require(len(present) == len(set(present)), "no Package child may be declared twice")
|
|
|
|
identity = package.find(f"{{{FOUNDATION}}}Identity")
|
|
if identity is None:
|
|
require(False, "the manifest must declare an Identity element")
|
|
else:
|
|
# makeappx rejects either schema violation before reading payloads.
|
|
require(
|
|
re.fullmatch(r"[-.A-Za-z0-9]{3,50}", identity.get("Name") or "") is not None,
|
|
"Identity/@Name must match the schema's [-.A-Za-z0-9]+ pattern; an underscore "
|
|
"is rejected by makeappx before it reads any payload",
|
|
)
|
|
require(
|
|
(identity.get("Publisher") or "").startswith("CN="),
|
|
"Identity/@Publisher must be the full distinguished name, starting with CN=",
|
|
)
|
|
require(
|
|
identity.get("Name") == IDENTITY_NAME
|
|
and identity.get("Publisher") == PUBLISHER
|
|
and package.findtext(
|
|
f"{{{FOUNDATION}}}Properties/{{{FOUNDATION}}}PublisherDisplayName"
|
|
)
|
|
== PUBLISHER_DISPLAY_NAME,
|
|
"the manifest must carry the identity reserved in Partner Center "
|
|
f"({IDENTITY_NAME}, {PUBLISHER}, {PUBLISHER_DISPLAY_NAME}); any drift fails "
|
|
"Store validation",
|
|
)
|
|
require(
|
|
identity.get("Version") == values["MsixVersion"],
|
|
"Identity/@Version must be the pubspec version plus the Store-reserved "
|
|
f"revision field: expected {values['MsixVersion']}",
|
|
)
|
|
|
|
capabilities = [
|
|
child.get("Name") for child in package.findall(f"{{{FOUNDATION}}}Capabilities/*")
|
|
]
|
|
for capability in REQUIRED_CAPABILITIES:
|
|
require(
|
|
capability in capabilities,
|
|
f"the manifest must declare the {capability} capability",
|
|
)
|
|
require(
|
|
"Windows.Desktop"
|
|
in [
|
|
child.get("Name")
|
|
for child in package.findall(
|
|
f"{{{FOUNDATION}}}Dependencies/{{{FOUNDATION}}}TargetDeviceFamily"
|
|
)
|
|
],
|
|
"Dependencies must target Windows.Desktop; packaging fails without a device family",
|
|
)
|
|
|
|
application = package.find(f"{{{FOUNDATION}}}Applications/{{{FOUNDATION}}}Application")
|
|
binary_name = re.search(
|
|
r'(?m)^set\(BINARY_NAME "([^"]+)"\)$', CMAKE.read_text(encoding="utf-8")
|
|
)
|
|
require(binary_name is not None, "windows/CMakeLists.txt must set BINARY_NAME")
|
|
if application is None:
|
|
require(False, "the manifest must declare an Application element")
|
|
elif binary_name is not None:
|
|
require(
|
|
application.get("Executable") == f"{binary_name.group(1)}.exe",
|
|
"Application/@Executable must be the executable windows/CMakeLists.txt builds, "
|
|
f"{binary_name.group(1)}.exe",
|
|
)
|
|
require(
|
|
application.get("EntryPoint") == "Windows.FullTrustApplication",
|
|
"a packaged Win32 app must enter through Windows.FullTrustApplication",
|
|
)
|
|
|
|
# Asset paths appear in attributes and element text.
|
|
referenced = {
|
|
match.group(1)
|
|
for element in package.iter()
|
|
for value in (*element.attrib.values(), element.text or "")
|
|
if (match := ASSET_REFERENCE.fullmatch(value.strip()))
|
|
}
|
|
for asset in REQUIRED_ASSETS:
|
|
require(asset in referenced, f"the manifest must reference {asset}")
|
|
for asset in sorted(referenced):
|
|
require(
|
|
(ASSETS / asset).is_file(),
|
|
f"windows/msix/assets/{asset} is referenced by the manifest but is not in the "
|
|
"tree; packaging fails on a missing asset",
|
|
)
|
|
|
|
# Recompute the Partner Center package-family suffix to catch publisher drift.
|
|
require(
|
|
package_family_suffix(PUBLISHER) == PACKAGE_FAMILY_SUFFIX,
|
|
f"the pinned publisher must hash to the package family name reported by Partner "
|
|
f"Center, {IDENTITY_NAME}_{PACKAGE_FAMILY_SUFFIX}",
|
|
)
|
|
|
|
require(
|
|
'Version="$MsixVersion"' in manifest_template,
|
|
"Identity/@Version must come from the single version variable, not a literal",
|
|
)
|
|
require(
|
|
'ProcessorArchitecture="$Architecture"' in manifest_template,
|
|
"Identity/@ProcessorArchitecture must be interpolated, not pinned to one architecture",
|
|
)
|
|
require(
|
|
len(re.findall(r"(?m)^\$MsixVersion = ", text)) == 1,
|
|
"the MSIX version must be derived once, so the bundle and both packages agree",
|
|
)
|
|
require(
|
|
re.search(r'(?m)^ return "\$\w+\.0"$', text) is not None,
|
|
"the MSIX version must append the Store-reserved revision field as 0",
|
|
)
|
|
require(
|
|
r"($Version -split '\+')[0]" in text,
|
|
"pubspec's +build metadata must be stripped; the Store reserves the fourth field",
|
|
)
|
|
require(
|
|
r"'^\d+\.\d+\.\d+$'" in text,
|
|
"a version that is not major.minor.patch must be rejected instead of packaged",
|
|
)
|
|
|
|
# PowerShell does not surface native exit codes; centralize the $LASTEXITCODE check.
|
|
require(
|
|
len(re.findall(r"(?m)^ & \$Tool @Arguments$", text)) == 1
|
|
and "& $MakeAppx" not in text
|
|
and "& $MakePri" not in text
|
|
and "$LASTEXITCODE -ne 0" in text,
|
|
"every SDK tool call must go through the single invocation that checks $LASTEXITCODE",
|
|
)
|
|
|
|
# Unplated variants prevent the shell's accent-colored plate; resources.pri resolves them.
|
|
for form in ("altform-unplated", "altform-lightunplated"):
|
|
require(
|
|
any(ASSETS.glob(f"Square44x44Logo.targetsize-*_{form}.png")),
|
|
f"the small logo needs targetsize {form} variants, or the shell draws the taskbar "
|
|
"icon on an accent-coloured plate",
|
|
)
|
|
require(
|
|
'"new", "/pr"' in text and "resources.pri" in text,
|
|
"the staged payload must be indexed into resources.pri, or every qualified logo "
|
|
"variant is inert payload",
|
|
)
|
|
require(
|
|
"RemoveChild($Packaging)" in text,
|
|
"autoResourcePackage must be stripped from the PRI config; it moves the qualified "
|
|
"logos into resource-package indexes that a per-architecture .msix never carries",
|
|
)
|
|
require(
|
|
"signtool" not in text.lower(),
|
|
"the bundle must stay unsigned; the Store re-signs it, and any other certificate "
|
|
"fails publisher-identity validation",
|
|
)
|
|
|
|
# The workflow supplies the manifest version from pubspec.yaml.
|
|
workflow = WORKFLOW.read_text(encoding="utf-8")
|
|
package_windows = job_block(workflow, "package-windows")
|
|
require(bool(package_windows), "missing package-windows job")
|
|
msix_step = named_step(package_windows, MSIX_STEP)
|
|
for argument in (
|
|
".\\windows\\build-msix.ps1",
|
|
'-X64BuildDir "build-x64"',
|
|
'-Arm64BuildDir "build-arm64"',
|
|
'-Version "${{ steps.version.outputs.version }}"',
|
|
):
|
|
require(
|
|
argument in msix_step,
|
|
f"the MSIX step must reuse the existing packaging inputs: {argument}",
|
|
)
|
|
require(
|
|
"pubspec.yaml" in package_windows,
|
|
"the version handed to both packaging scripts must be read from pubspec.yaml",
|
|
)
|
|
require(
|
|
package_windows.count(BUNDLE) == 2,
|
|
f"{BUNDLE} must be attested and uploaded, exactly once each",
|
|
)
|
|
require(
|
|
BUNDLE not in job_block(workflow, "create-release"),
|
|
f"{BUNDLE} must not be attached to the GitHub release; a Store-identity package "
|
|
"cannot be installed without the Store certificate",
|
|
)
|
|
|
|
if errors:
|
|
for error in errors:
|
|
print(f"ERROR: {error}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print("windows msix manifest checks passed")
|