diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 16b8818..e4a29b8 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -36,7 +36,9 @@ jobs: build: name: ${{ matrix.label }} runs-on: ${{ matrix.os }} - timeout-minutes: 25 + # Windows gets an extra hour: a signed build waits for the maintainer to approve it on + # SignPath (see "Sign it" below), and that wait is spent inside this job. + timeout-minutes: ${{ matrix.os == 'windows-latest' && 90 || 25 }} strategy: fail-fast: false matrix: @@ -137,9 +139,17 @@ jobs: # Both legs are skipped wholesale until the account exists (the variable is unset # until then), so this file builds unsigned Windows binaries in the meantime rather # than failing, and a fork with no SignPath project of its own is unaffected. + # + # SignPath Foundation's terms make a person approve every signing request, so each + # one waits on the maintainer. Only a release (a tag, arriving through the release + # workflow's call) or a run started by hand asks; a push to `main` that touched the + # packaging builds unsigned, rather than sending an approval request nobody wanted + # and failing the run when it went unanswered. - name: Hand the Windows binary to SignPath id: unsigned - if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != '' + if: >- + runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != '' && + (github.ref_type == 'tag' || github.event_name == 'workflow_dispatch') uses: actions/upload-artifact@v4 with: # Deliberately not `meshterm-*`: the release workflow attaches every artifact @@ -149,7 +159,7 @@ jobs: if-no-files-found: error - name: Sign it - if: runner.os == 'Windows' && vars.SIGNPATH_ORGANIZATION_ID != '' + if: steps.unsigned.outcome == 'success' uses: signpath/github-action-submit-signing-request@v1 with: api-token: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -161,6 +171,11 @@ jobs: # The signed file comes back where the unsigned one was built, under the same # name, so every step after this one is untouched by any of it. wait-for-completion: true + # An hour to approve it, where the action's own default is ten minutes: long + # enough to see SignPath's email and answer it from a phone, so a release + # doesn't fail because the maintainer stepped away. The job's timeout above + # leaves room for it. + wait-for-completion-timeout-in-seconds: 3600 output-artifact-directory: packaging/dist - name: Name it after the version and the platform diff --git a/README.md b/README.md index 8136051..f240796 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,12 @@ Whichever you took, you now have a single file: `meshterm` on macOS and Linux, `meshterm-windows-x64.exe` on Windows. Put it somewhere on your `PATH` (renamed to `meshterm.exe` on Windows) and it's just `meshterm` from anywhere. -> **These builds aren't code-signed**, which is why macOS and Windows both push back the -> first time; signing costs real money on both platforms and this is a free side project. +> **These builds aren't code-signed yet**, which is why macOS and Windows both push back +> the first time. **Windows signing is on its way.** MeshTerm has applied to the +> [SignPath Foundation](https://signpath.org), which signs open-source projects for free. +> Once that's set up, every Windows download will be signed. The +> [code signing policy](#code-signing-policy) below explains how. Mac builds will stay +> unsigned, because Apple charges for signing and this is a free side project. > Every release ships a `SHA256SUMS` file if you'd rather check what you got, and > [installing with pip or pipx](#or-install-with-pip) sidesteps the whole business, since > nothing arrives as a downloaded binary. @@ -324,6 +328,35 @@ Also here: [CONTRIBUTING.md](CONTRIBUTING.md) · [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) · [CHANGELOG.md](CHANGELOG.md) +## Code signing policy + +Free code signing provided by [SignPath.io](https://signpath.io), certificate by +[SignPath Foundation](https://signpath.org). + +This is for the Windows download. It's being set up now, so releases up to and including +0.10.2 are still unsigned. + +**How a build gets signed.** Every Windows build is made by GitHub Actions, straight from +the public source code in this repository. SignPath only signs a file it can trace back to +one of those builds, and only after the maintainer approves that release. A file built on +anyone's own computer can't be signed this way, not even the maintainer's. + +**Who does what.** MeshTerm has one maintainer, +[Jean-Pierre Martineau](https://github.com/jpmartineau), who holds all three roles: + +- **Author:** writes the code and commits it. +- **Reviewer:** reviews every change from anyone else before it goes in. +- **Approver:** approves each release before it gets signed. + +**Privacy.** This program will not transfer any information to other networked systems +unless specifically requested by the user or the person installing or operating it. + +In practice, MeshTerm goes online for one thing: map tiles. It downloads them from +[OpenFreeMap](https://openfreemap.org/) when you open a screen that shows a map, and +OpenFreeMap's [privacy policy](https://openfreemap.org/privacy) covers those downloads. It +collects nothing about you and doesn't check for updates. Over the radio, it sends only +what you ask it to send. + ## Acknowledgements & attribution MeshTerm stands on the [MeshCore](https://meshcore.io/) project — its firmware and the diff --git a/packaging/meshterm.spec b/packaging/meshterm.spec index 144a998..f57b7c9 100644 --- a/packaging/meshterm.spec +++ b/packaging/meshterm.spec @@ -9,6 +9,7 @@ # Built on the machine it targets. There is no cross-compiling here — the Windows build # comes off a Windows runner, the macOS one off macOS. That is what the workflow is for. +import re import sys from PyInstaller.utils.hooks import collect_submodules @@ -34,6 +35,61 @@ third_party_notices = notices.write_third_party_notices( os.path.join(workpath, "THIRD-PARTY-NOTICES.txt") ) +# The Windows build's version resource: the Details tab of the file's Properties, and the +# name and version Windows shows for it. Without one the .exe is just "meshterm.exe" with +# no product behind it, and SignPath refuses to sign it — their signing configuration +# checks the product name and version written here, so a file that doesn't carry them +# can't be passed off as a MeshTerm release. Built from the package's own `__version__` +# rather than kept as a second copy that would drift. The other platforms have no such +# resource, and PyInstaller warns if handed one there. +version_info = None +if sys.platform == "win32": + from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, + ) + + # The checkout being frozen, not whichever MeshTerm the interpreter happens to have + # installed: `Analysis` below reads its sources from here too. + sys.path.insert(0, os.path.dirname(SPECPATH)) + import meshterm # noqa: E402 (import must follow the sys.path edit above) + + # The resource's fixed half holds exactly four numbers: 0.10.2 → (0, 10, 2, 0). A + # suffix such as `rc1` has nowhere to go there and is dropped; the text fields below + # keep the version exactly as written. + numbers = [int(n) for n in re.match(r"\d+(?:\.\d+)*", meshterm.__version__)[0].split(".")] + numbers = tuple((numbers + [0, 0, 0, 0])[:4]) + version_info = VSVersionInfo( + ffi=FixedFileInfo(filevers=numbers, prodvers=numbers), + kids=[ + StringFileInfo( + [ + # 0409 is US English and 04B0 says the strings are Unicode — the + # pairing nearly every Windows program declares. + StringTable( + "040904B0", + [ + StringStruct("CompanyName", meshterm.__author__), + StringStruct("FileDescription", "MeshTerm"), + StringStruct("FileVersion", meshterm.__version__), + StringStruct("InternalName", "meshterm"), + StringStruct("LegalCopyright", meshterm.copyright_notice()), + StringStruct("OriginalFilename", "meshterm.exe"), + StringStruct("ProductName", "MeshTerm"), + StringStruct("ProductVersion", meshterm.__version__), + ], + ) + ] + ), + VarFileInfo([VarStruct("Translation", [0x0409, 1200])]), + ], + ) + # The assets have to land at `meshterm/assets`, because `ui/about.py` finds them with # `Path(__file__).parent.parent / "assets"` and that path has to keep resolving inside # the bundle exactly as it does in a checkout. @@ -105,4 +161,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, + version=version_info, )