Skip to content
Open
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ jobs:
python -c "import glob, tarfile;
names = tarfile.open(glob.glob('dist/*.tar.gz')[0]).getnames();
assert all(any(n.endswith('/build/windows/' + f) for n in names)
for f in ('build.py', 'sign.py', 'offloader.spec', 'installer.nsi'))"
for f in ('build.py', 'sign.py', 'sbom.py', 'offloader.spec', 'installer.nsi'))"
- name: Install and check the built wheel outside the checkout
run: python scripts/check_wheel.py dist/*.whl
- uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -156,5 +156,8 @@ jobs:
dist/windows/Offloader-*.exe
dist/windows/Offloader-*.zip
dist/windows/Offloader-*-inventory.json
dist/windows/Offloader-*-sbom.cyclonedx.json
dist/windows/Offloader-*-third-party-notices.txt
dist/windows/Offloader-*-requirements.txt
dist/windows/SHA256SUMS.txt
if-no-files-found: error
6 changes: 6 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ jobs:
for name in "Offloader-Setup-${version}.exe" \
"Offloader-${version}-windows-x64.zip" \
"Offloader-${version}-inventory.json" \
"Offloader-${version}-sbom.cyclonedx.json" \
"Offloader-${version}-third-party-notices.txt" \
"Offloader-${version}-requirements.txt" \
"SHA256SUMS.txt"; do
test -f "$name" || { echo "missing artifact: $name"; exit 1; }
done
Expand All @@ -87,6 +90,9 @@ jobs:
dist/windows/Offloader-*.exe
dist/windows/Offloader-*.zip
dist/windows/Offloader-*-inventory.json
dist/windows/Offloader-*-sbom.cyclonedx.json
dist/windows/Offloader-*-third-party-notices.txt
dist/windows/Offloader-*-requirements.txt
dist/windows/SHA256SUMS.txt
if-no-files-found: error
retention-days: 30
Expand Down
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@ project uses [semantic versioning][semver].

### Added

- **A bill of materials, third-party notices and a pinned lockfile.** Every
build emits a CycloneDX 1.6 SBOM, a human-readable notices inventory and a
pinned requirements file, checksummed with the other release outputs and
named in the upload instructions that publish them. One list of release
assets is read by the build that checksums them, by the verification that
refuses anything it did not expect, and by the generated release notes, so
the instructions cannot name fewer files than the checksums cover. These are
written beside the bundle, not embedded in the installer, so they accompany
a release by being uploaded with it.

**They cover the Python distribution dependencies, and say so.** A frozen
application also ships the CPython runtime DLL and the PyInstaller bootloader
compiled into each executable, neither of which has packaging metadata for
the closure to walk. Each output states that boundary in its own text and
names what is outside it, because an inventory read as complete while missing
the interpreter it ships is worse than one that says where it stops. The
complete third-party inventory remains a release gate, and the gap is
measured against a built bundle rather than asserted from a list.

The set is the runtime dependency closure of the installed package, not the
build environment: the release inventory already records every distribution
present, which on a developer machine includes pytest and ruff. Right for
reproducing a build, wrong as a statement about what ships. A package that
is required but not installed is an error rather than a silent omission.

Each component records **which metadata field its licence came from**, since
a PEP 639 `License-Expression` is a precise claim and a classifier's "BSD
License" is not. Prose in the free-text field is marked as loose rather than
truncated into something resembling an SPDX identifier, and only a real
expression is emitted as CycloneDX `expression`. The serial number is derived
from the contents, so the same inputs produce the same document and two SBOMs
can be diffed.

Licences with redistribution conditions beyond attribution are flagged for
review and listed on stderr. **Qt ships under `LGPL-3.0-only OR GPL-2.0-only
OR GPL-3.0-only` while Offloader is MIT**, so all four PySide6/shiboken6
packages are flagged. That is deliberately a prompt and not a verdict: what
those terms require of a frozen bundle is a decision for a person, and the
tool's job is to make it impossible to miss.

- **The desktop app checks for updates, and declines while a job is running.**
One check a couple of seconds after the window opens, saying nothing unless
there is something to say, with the release named in the header and
Expand Down
28 changes: 28 additions & 0 deletions build/windows/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,34 @@
from typing import Any

_VERSION_RE = re.compile(r'^__version__\s*=\s*["\']([^"\']+)["\']', re.MULTILINE)


def sbom_names(version: str) -> list[str]:
"""The bill-of-materials files a release carries, in upload order."""
return [
f"Offloader-{version}-sbom.cyclonedx.json",
f"Offloader-{version}-third-party-notices.txt",
f"Offloader-{version}-requirements.txt",
]


def release_assets(version: str) -> list[str]:
"""Every file that has to be attached to a published release.

One list, because three places have to agree about it: the build
checksums these, its own verification refuses anything it did not expect,
and the generated release instructions upload them. They had drifted --
the bill-of-materials files entered `SHA256SUMS.txt` while the upload
command still named only the installer, the checksums and the inventory,
so following the instructions published checksums for assets that were not
there.
"""
return [
f"Offloader-Setup-{version}.exe",
"SHA256SUMS.txt",
f"Offloader-{version}-inventory.json",
*sbom_names(version),
]
_SOURCE_SUFFIXES = {".py", ".spec", ".nsi", ".nsh", ".ico", ".bmp"}
_RECORD_NAME = ".offloader-build.json"
_MAX_RECORD_BYTES = 4 * 1024 * 1024
Expand Down
24 changes: 22 additions & 2 deletions build/windows/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@

HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
# The sibling build scripts are imported by name from inside functions. Running
# this file directly puts its directory on the path; importing it as a module,
# which the tests do, does not.
if str(HERE) not in sys.path:
sys.path.insert(0, str(HERE))
SPEC = HERE / "offloader.spec"
DIST = REPO / "dist" / "windows"
WORK = REPO / ".pyinstaller" / "windows"
Expand Down Expand Up @@ -57,8 +62,22 @@ def check_signatures(bundle: Path, *, signing: bool, version: str) -> list[dict]
return records


def sbom_names(version: str) -> set[str]:
"""The bill-of-materials files a release carries.

Named in `artifacts` because four places have to agree about them:
`save_outputs` checksums them, `validate_outputs` refuses anything it did
not expect, and `scripts/release_notes.py` writes the upload command that
publishes them.
"""
from artifacts import sbom_names as names

return set(names(version))


def save_outputs(bundle: Path, setup: Path | None, identity: dict,
signatures: list[dict], signed: bool) -> None:
import sbom
from artifacts import bundle_inventory

version = identity["version"]
Expand All @@ -77,7 +96,7 @@ def save_outputs(bundle: Path, setup: Path | None, identity: dict,
for path in sorted(bundle.rglob("*")):
if path.is_file():
output.write(path, f"Offloader/{path.relative_to(bundle).as_posix()}")
outputs = [archive, inventory]
outputs = [archive, inventory, *sbom.write_all(DIST, identity)]

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include the new BOM artifacts in the signed-release upload instructions.

These three files now enter SHA256SUMS.txt, but the upload command generated by scripts/release_notes.py:44 and the command in docs/build-windows.md still upload only Setup, SHA256SUMS, and the inventory. Following the supplied release instructions therefore publishes checksums for unavailable SBOM/notices/requirements assets. The notices also do not accompany the release as this PR describes; generating them beside the bundle does not embed them in the existing installer.

Add all three artifact names to both upload instructions, preferably from a shared artifact-name helper. Test the generated release-note list against sbom_names() so adding another required output cannot leave the publication step behind.

if setup is not None:
outputs.append(setup)
lines = []
Expand All @@ -101,7 +120,8 @@ def validate_outputs(bundle: Path, setup: Path | None, identity: dict) -> None:
raise RuntimeError("Output inventory is unsigned or belongs to different sources")
if record.get("files") != bundle_inventory(bundle):
raise RuntimeError("Output inventory no longer matches the bundle")
expected = {inventory.name, f"Offloader-{version}-windows-x64.zip"}
expected = {inventory.name, f"Offloader-{version}-windows-x64.zip",
*sbom_names(version)}
if setup is not None:
expected.add(setup.name)
checksums = {}
Expand Down
Loading
Loading