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
13 changes: 13 additions & 0 deletions .github/workflows/native-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,13 @@ jobs:
python scripts/native_release.py validate-sbom
--file release-assets/OpenAdapt-Desktop-${{ github.ref_name }}.cyclonedx.json

- name: Generate website-readable release manifest
run: >-
python scripts/native_release.py website-manifest
--directory release-assets
--tag "${{ github.ref_name }}"
--sbom release-assets/OpenAdapt-Desktop-${{ github.ref_name }}.cyclonedx.json

- name: Generate and verify final checksums
run: |
python scripts/native_release.py checksums \
Expand All @@ -529,6 +536,12 @@ jobs:
--directory release-assets \
--manifest release-assets/SHA256SUMS

- name: Validate website manifest against exact checksummed bytes
run: >-
python scripts/native_release.py validate-website-manifest
--file release-assets/openadapt-desktop-release-manifest.json
--checksums release-assets/SHA256SUMS

- name: Attest the exact files named by SHA256SUMS
uses: actions/attest@508db95dd578ae2727ebd6217d5ba78e4fbda05d # v4
with:
Expand Down
9 changes: 8 additions & 1 deletion RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ download, and how the lanes converge.
| Lane | Tag | Trigger | Marked as | Assets |
| --- | --- | --- | --- | --- |
| Engine (Python package) | `vX.Y.Z` | Explicit `Release and PyPI Publish` dispatch from green protected `main` | Regular release ("Latest") | Wheel, sdist, PyPI publish attestations, **and a mirrored copy of the matching `desktop-vX.Y.Z` installer set** |
| Native installers | `desktop-vX.Y.Z` | `desktop-v*` tag push (automated, see below) | Draft, then published **prerelease** | Beta DMG (macOS arm64/x86_64), MSI + NSIS (Windows x86_64), DEB + AppImage (Linux x86_64), per-platform metadata JSON, and one `SHA256SUMS` manifest with GitHub artifact attestations |
| Native installers | `desktop-vX.Y.Z` | `desktop-v*` tag push (automated, see below) | Draft, then published **prerelease** | Beta DMG (macOS arm64/x86_64), MSI + NSIS (Windows x86_64), DEB + AppImage (Linux x86_64), per-platform metadata JSON, a website-readable release manifest, and one `SHA256SUMS` manifest with GitHub artifact attestations |

The engine lane stays non-prerelease so GitHub's "Latest" pointer always names
the canonical engine release. The native lane stays prerelease because its
Expand Down Expand Up @@ -144,6 +144,13 @@ alone:
`<!-- installer-release -->` marker. Selection logic must keep matching on
the marker plus the `desktop-v` prefix, so the mirror is invisible to it. The
mirror exists for humans who land on `/releases/latest`.
- `openadapt-desktop-release-manifest.json` is the website-readable index. It
lists each artifact name, platform, architecture, signing state, and SHA-256,
plus the checked CycloneDX SBOM. The release workflow recomputes the named
hashes and validates the complete file set against `SHA256SUMS` before it
creates the GitHub attestation. A consumer must still verify the checksums
and attestation; the manifest does not claim that unsigned artifacts are
signed.

## Convergence plan (post-signing)

Expand Down
224 changes: 213 additions & 11 deletions scripts/native_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@
("windows", "x86_64"),
("linux", "x86_64"),
}
WEBSITE_RELEASE_MANIFEST = "openadapt-desktop-release-manifest.json"
WEBSITE_RELEASE_VERIFICATION = {
"sha256_manifest": "SHA256SUMS",
"github_artifact_attestation": "required",
"installer_smoke": "install, launch, and uninstall",
}
WEBSITE_RELEASE_SBOM_FORMAT = "CycloneDX"


def native_versions(root: Path = ROOT) -> dict[str, str]:
Expand Down Expand Up @@ -91,9 +98,7 @@ def set_native_version(version: str, root: Path = ROOT) -> dict[str, str]:
def rewrite_json(path: Path, mutate) -> None:
data = json.loads(path.read_text(encoding="utf-8"))
mutate(data)
path.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
)
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")

def set_lock_versions(lock: dict) -> None:
lock["version"] = version
Expand Down Expand Up @@ -205,7 +210,7 @@ def installer_pointer_notes(body: str, native_tag: str, repo: str) -> str | None
"> macOS DMG (arm64 and x86_64), Windows MSI and NSIS `.exe`, Linux\n"
"> `.deb` and `.AppImage`, plus `SHA256SUMS` — the same attested bytes\n"
f"> published at [`{native_tag}`]({base}/tag/{native_tag}), mirrored\n"
"> here so GitHub's \"Latest\" always carries an installer.\n"
'> here so GitHub\'s "Latest" always carries an installer.\n'
">\n"
"> **These installers are Beta and are ad-hoc-signed (macOS) or\n"
"> unsigned (Windows, Linux)** pending signing credentials; the signing\n"
Expand Down Expand Up @@ -326,13 +331,9 @@ def write_checksums(directory: Path, output: Path) -> list[tuple[str, str]]:


def verify_checksums(directory: Path, manifest: Path) -> int:
entries = read_checksums(manifest)
checked = 0
for line in manifest.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
digest, separator, name = line.partition(" ")
if not separator or len(digest) != 64 or Path(name).name != name:
raise ValueError(f"invalid checksum line: {line!r}")
for name, digest in entries.items():
path = directory / name
if not path.is_file():
raise ValueError(f"checksum target is missing: {path}")
Expand All @@ -345,6 +346,29 @@ def verify_checksums(directory: Path, manifest: Path) -> int:
return checked


def read_checksums(manifest: Path) -> dict[str, str]:
"""Read a strict SHA256SUMS file and reject duplicate asset names."""

entries: dict[str, str] = {}
for line in manifest.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
digest, separator, name = line.partition(" ")
if (
not separator
or not re.fullmatch(r"[0-9a-f]{64}", digest)
or Path(name).name != name
or not name
):
raise ValueError(f"invalid checksum line: {line!r}")
if name in entries:
raise ValueError(f"duplicate checksum target: {name}")
entries[name] = digest
if not entries:
raise ValueError("checksum manifest is empty")
return entries


def validate_release_set(directory: Path) -> int:
files = sorted(path for path in directory.iterdir() if path.is_file())
metadata_paths = [path for path in files if path.name.endswith("-metadata.json")]
Expand Down Expand Up @@ -398,7 +422,13 @@ def validate_release_set(directory: Path) -> int:
actual_assets = {
path.name
for path in files
if not path.name.endswith("-metadata.json") and path.name != "SHA256SUMS"
if not path.name.endswith("-metadata.json")
and path.name != "SHA256SUMS"
# These release-wide integrity documents are generated after the
# per-platform staging metadata. They describe the installer set but
# are not installers themselves.
and not path.name.endswith(".cyclonedx.json")
and path.name != WEBSITE_RELEASE_MANIFEST
}
if actual_assets != referenced_assets:
raise ValueError(
Expand Down Expand Up @@ -437,6 +467,165 @@ def validate_sbom(path: Path) -> int:
return len(components)


def write_website_release_manifest(
directory: Path, *, tag: str, sbom: Path, root: Path = ROOT
) -> Path:
"""Write the verified, public release index consumed by download pages.

This is deliberately a description of the exact staged bytes, not a claim
that an unsigned artifact is signed. ``SHA256SUMS`` subsequently binds the
manifest itself into the GitHub provenance attestation.
"""

validate_tag(tag, root)
validate_release_set(directory)
validate_sbom(sbom)
if sbom.parent.resolve() != directory.resolve():
raise ValueError("SBOM must be inside the release asset directory")
output = directory / WEBSITE_RELEASE_MANIFEST
if output.exists():
raise ValueError(f"release manifest already exists: {output}")

assets: list[dict[str, str]] = []
for metadata_path in sorted(directory.glob("*-metadata.json")):
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
for name in metadata["artifacts"]:
path = directory / name
assets.append(
{
"name": name,
"platform": metadata["platform"],
"architecture": metadata["architecture"],
"signing": metadata["signing"],
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
}
)
assets.sort(key=lambda item: item["name"])
payload = {
"schema_version": 1,
"lifecycle": LIFECYCLE,
"native_tag": tag,
"native_version": native_version(root),
"source_commit": os.environ.get("GITHUB_SHA", "local"),
"verification": WEBSITE_RELEASE_VERIFICATION,
"sbom": {
"name": sbom.name,
"sha256": hashlib.sha256(sbom.read_bytes()).hexdigest(),
"format": WEBSITE_RELEASE_SBOM_FORMAT,
},
"artifacts": assets,
}
output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return output


def validate_website_release_manifest(path: Path, *, checksums: Path, root: Path = ROOT) -> int:
"""Validate the public index against metadata, bytes, SBOM, and checksums."""

data = json.loads(path.read_text(encoding="utf-8"))
if data.get("schema_version") != 1 or data.get("lifecycle") != LIFECYCLE:
raise ValueError(f"invalid website release manifest: {path}")
validate_tag(str(data.get("native_tag", "")), root)
if data.get("native_version") != native_version(root):
raise ValueError(f"wrong native version in website release manifest: {path}")
if data.get("verification") != WEBSITE_RELEASE_VERIFICATION:
raise ValueError("website release manifest has an invalid verification contract")
directory = path.parent
if path.name != WEBSITE_RELEASE_MANIFEST or checksums.parent.resolve() != directory.resolve():
raise ValueError("website manifest and SHA256SUMS must be in the release directory")
validate_release_set(directory)
metadata_by_asset: dict[str, tuple[str, str, str]] = {}
for metadata_path in sorted(directory.glob("*-metadata.json")):
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
for name in metadata.get("artifacts", []):
if name in metadata_by_asset:
raise ValueError(f"duplicate artifact in platform metadata: {name}")
metadata_by_asset[name] = (
metadata.get("platform"),
metadata.get("architecture"),
metadata.get("signing"),
)
# Each release has one signing mode per platform. Platform metadata is the
# source of truth for which permitted mode the build selected.
expected_names = set(metadata_by_asset)
assets = data.get("artifacts")
if not isinstance(assets, list) or len(assets) != len(expected_names):
raise ValueError(f"website release manifest has an incomplete artifact set: {path}")
observed_names: set[str] = set()
for asset in assets:
if not isinstance(asset, dict) or asset.get("signing") not in SIGNING_MODES.get(
asset.get("platform"), set()
):
raise ValueError(f"website release manifest has invalid signing metadata: {path}")
name = asset.get("name")
digest = asset.get("sha256")
if (
not isinstance(name, str)
or name in observed_names
or name not in expected_names
or not isinstance(digest, str)
or not re.fullmatch(r"[0-9a-f]{64}", digest)
):
raise ValueError(f"website release manifest has invalid artifact digest: {path}")
observed_names.add(name)
platform, architecture, signing = metadata_by_asset[name]
if (asset.get("platform"), asset.get("architecture"), asset.get("signing")) != (
platform,
architecture,
signing,
):
raise ValueError(f"website release manifest disagrees with platform metadata: {name}")
artifact_path = directory / name
if not artifact_path.is_file():
raise ValueError(f"website release manifest references missing artifact: {name}")
actual_digest = hashlib.sha256(artifact_path.read_bytes()).hexdigest()
if digest != actual_digest:
raise ValueError(f"website release manifest digest differs for {name}")
if observed_names != expected_names:
raise ValueError("website release manifest artifact names are incomplete")

sbom = data.get("sbom")
expected_sbom_name = f"OpenAdapt-Desktop-{data['native_tag']}.cyclonedx.json"
if (
not isinstance(sbom, dict)
or set(sbom) != {"name", "sha256", "format"}
or sbom.get("name") != expected_sbom_name
):
raise ValueError("website release manifest names the wrong SBOM")
if sbom.get("format") != WEBSITE_RELEASE_SBOM_FORMAT:
raise ValueError("website release manifest has an invalid SBOM format")
sbom_path = directory / expected_sbom_name
if not sbom_path.is_file():
raise ValueError(f"website release manifest references missing SBOM: {sbom_path}")
validate_sbom(sbom_path)
sbom_digest = hashlib.sha256(sbom_path.read_bytes()).hexdigest()
if sbom.get("sha256") != sbom_digest:
raise ValueError("website release manifest SBOM digest differs")

checksum_entries = read_checksums(checksums)
metadata_names = {metadata.name for metadata in directory.glob("*-metadata.json")}
expected_checksum_names = (
expected_names | metadata_names | {expected_sbom_name, WEBSITE_RELEASE_MANIFEST}
)
if set(checksum_entries) != expected_checksum_names:
raise ValueError("SHA256SUMS does not describe the exact release file set")
for name in (
expected_names
| metadata_names
| {
expected_sbom_name,
WEBSITE_RELEASE_MANIFEST,
}
):
actual_digest = hashlib.sha256((directory / name).read_bytes()).hexdigest()
if checksum_entries.get(name) != actual_digest:
raise ValueError(f"SHA256SUMS digest differs for {name}")
expected_commit = os.environ.get("GITHUB_SHA")
if expected_commit and data.get("source_commit") != expected_commit:
raise ValueError("website release manifest source commit differs")
return len(assets)


def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest="command", required=True)
Expand Down Expand Up @@ -481,6 +670,13 @@ def _parser() -> argparse.ArgumentParser:
validate_set_parser.add_argument("--directory", type=Path, required=True)
validate_sbom_parser = subparsers.add_parser("validate-sbom")
validate_sbom_parser.add_argument("--file", type=Path, required=True)
website_manifest_parser = subparsers.add_parser("website-manifest")
website_manifest_parser.add_argument("--directory", type=Path, required=True)
website_manifest_parser.add_argument("--tag", required=True)
website_manifest_parser.add_argument("--sbom", type=Path, required=True)
validate_website_manifest_parser = subparsers.add_parser("validate-website-manifest")
validate_website_manifest_parser.add_argument("--file", type=Path, required=True)
validate_website_manifest_parser.add_argument("--checksums", type=Path, required=True)
return parser


Expand Down Expand Up @@ -545,6 +741,12 @@ def main() -> int:
elif args.command == "validate-sbom":
count = validate_sbom(args.file)
print(f"Validated {count} components in {args.file}")
elif args.command == "website-manifest":
path = write_website_release_manifest(args.directory, tag=args.tag, sbom=args.sbom)
print(path)
elif args.command == "validate-website-manifest":
count = validate_website_release_manifest(args.file, checksums=args.checksums)
print(f"Validated {count} website release artifacts in {args.file}")
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
Expand Down
Loading