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
6 changes: 5 additions & 1 deletion .github/BRANCH_PROTECTION_RULESETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This repository uses two GitHub branch protection rulesets.
1. **Default branch (main)**
- Target: branch name `main`.
- Require a pull request, stale review dismissal, and resolved review threads (self-approval allowed; no mandatory external approval).
- Require status checks: **Analyze (python)**, **Unit tests (3.11)**, **Unit tests (3.12)**, **Compile + help smoke (macos-latest, 3.11)**, **Compile + help smoke (windows-latest, 3.11)**, **No build artifacts tracked**.
- Require status checks: **Analyze (python)**, **Unit tests (3.11)**, **Unit tests (3.12)**, **Compile + help smoke (macos-latest, 3.11)**, **Compile + help smoke (windows-latest, 3.11)**, **Windows unit tests**, **Windows MSI smoke**, **No build artifacts tracked**.
- Require linear history.
- Block force pushes and branch deletion.
- Bypass: none configured in rulesets.
Expand All @@ -34,6 +34,8 @@ This repository uses two GitHub branch protection rulesets.
- **Unit tests (3.12)**
- **Compile + help smoke (macos-latest, 3.11)**
- **Compile + help smoke (windows-latest, 3.11)**
- **Windows unit tests**
- **Windows MSI smoke**
- **No build artifacts tracked**

## Optional: apply via API
Expand All @@ -48,6 +50,8 @@ CONTEXTS='[
{"context":"Unit tests (3.12)"},
{"context":"Compile + help smoke (macos-latest, 3.11)"},
{"context":"Compile + help smoke (windows-latest, 3.11)"},
{"context":"Windows unit tests"},
{"context":"Windows MSI smoke"},
{"context":"No build artifacts tracked"}
]'

Expand Down
2 changes: 2 additions & 0 deletions .github/ruleset-main.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
{ "context": "Unit tests (3.12)" },
{ "context": "Compile + help smoke (macos-latest, 3.11)" },
{ "context": "Compile + help smoke (windows-latest, 3.11)" },
{ "context": "Windows unit tests" },
{ "context": "Windows MSI smoke" },
{ "context": "No build artifacts tracked" }
]
}
Expand Down
2 changes: 2 additions & 0 deletions .github/ruleset-release.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
{ "context": "Unit tests (3.12)" },
{ "context": "Compile + help smoke (macos-latest, 3.11)" },
{ "context": "Compile + help smoke (windows-latest, 3.11)" },
{ "context": "Windows unit tests" },
{ "context": "Windows MSI smoke" },
{ "context": "No build artifacts tracked" }
]
}
Expand Down
54 changes: 51 additions & 3 deletions .github/workflows/bootstrap-winget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ name: Bootstrap WinGet package

"on":
workflow_dispatch:
inputs:
release_tag:
description: "Published release tag to submit (for example v0.1.7)"
required: true
default: "v0.1.7"
type: string

permissions:
contents: read
Expand Down Expand Up @@ -80,17 +86,59 @@ jobs:
$ErrorActionPreference = "Stop"
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe -UseBasicParsing

- name: Prepare manifest from published release
id: prepare
shell: pwsh
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
$ErrorActionPreference = "Stop"
if ($env:RELEASE_TAG -notmatch '^v(\d+\.\d+\.\d+(?:\.\d+)?)$') {
throw "Release tag must use vX.Y.Z or vX.Y.Z.W."
}
$version = $Matches[1]
$headers = @{
Authorization = "Bearer $env:WINGET_TOKEN"
Accept = "application/vnd.github+json"
"X-GitHub-Api-Version" = "2022-11-28"
}
$release = Invoke-RestMethod `
-Headers $headers `
-Uri "https://api.github.com/repos/wildfoundry/dataplicity-cli/releases/tags/$env:RELEASE_TAG"
$assetName = "dataplicity-cli-$version-windows-x64.msi"
$asset = $release.assets | Where-Object { $_.name -eq $assetName } | Select-Object -First 1
if (-not $asset) {
throw "Release $env:RELEASE_TAG does not contain $assetName."
}
$downloadPath = Join-Path $env:RUNNER_TEMP $assetName
Invoke-WebRequest $asset.browser_download_url -OutFile $downloadPath -UseBasicParsing
$sha256 = (Get-FileHash $downloadPath -Algorithm SHA256).Hash
$releaseDate = ([DateTime]$release.published_at).ToString("yyyy-MM-dd")
$outputRoot = Join-Path $env:RUNNER_TEMP "winget-manifests"
$manifestOutput = python build/winget/prepare_manifest.py `
--source-dir "build/winget/manifests/w/Wildfoundry/DataplicityCLI/0.1.6" `
--output-root $outputRoot `
--version $version `
--installer-url $asset.browser_download_url `
--installer-sha256 $sha256 `
--release-date $releaseDate
$manifestPath = ($manifestOutput | Select-Object -Last 1).Trim()
"manifest_path=$manifestPath" >> $env:GITHUB_OUTPUT
"package_version=$version" >> $env:GITHUB_OUTPUT

- name: Submit initial manifest
shell: pwsh
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
MANIFEST_PATH: ${{ steps.prepare.outputs.manifest_path }}
PACKAGE_VERSION: ${{ steps.prepare.outputs.package_version }}
run: |
$ErrorActionPreference = "Stop"
$manifestPath = "build/winget/manifests/w/Wildfoundry/DataplicityCLI/0.1.6"
$prTitle = "New package: Wildfoundry.DataplicityCLI version 0.1.6"
$prTitle = "New package: Wildfoundry.DataplicityCLI version $env:PACKAGE_VERSION"

./wingetcreate.exe submit `
--token $env:WINGET_TOKEN `
--prtitle $prTitle `
--no-open `
$manifestPath
$env:MANIFEST_PATH
83 changes: 83 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,90 @@
- name: Help smoke
run: python -m dataplicity_cli --help

windows-unit-tests:
name: Windows unit tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[test]"
- name: Run unit tests
run: pytest -q --maxfail=1

windows-package-smoke:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +52 to +66
name: Windows MSI smoke
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install . pyinstaller
choco install wixtoolset -y
- name: Build executable and MSI
shell: pwsh
run: |
$env:VERSION = python build/get_version.py
pyinstaller --noconfirm --clean --onefile --name dataplicity `
--workpath pyinstaller-build --distpath pyinstaller-dist `
dataplicity_cli/__main__.py
./pyinstaller-dist/dataplicity.exe --version
./pyinstaller-dist/dataplicity.exe --help
./build/windows/build_msi.ps1 -Version $env:VERSION
"MSI_PATH=$((Resolve-Path "dist/dataplicity-cli-$env:VERSION-windows-x64.msi").Path)" >> $env:GITHUB_ENV
- name: Install, verify, and uninstall MSI
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$installDir = Join-Path $env:ProgramFiles "Dataplicity\Dataplicity CLI"
$installedExe = Join-Path $installDir "dataplicity.exe"
$installed = $false
try {
$install = Start-Process msiexec.exe `
-ArgumentList @("/i", "`"$env:MSI_PATH`"", "/qn", "/norestart") `
-Wait -PassThru
if ($install.ExitCode -ne 0) {
throw "MSI install failed with exit code $($install.ExitCode)."
}
$installed = $true
if (!(Test-Path $installedExe)) {
throw "Installed executable not found at $installedExe."
}
& $installedExe --version
if ($LASTEXITCODE -ne 0) {
throw "Installed executable --version failed."
}
& $installedExe --help
if ($LASTEXITCODE -ne 0) {
throw "Installed executable --help failed."
}
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
if ($installDir -notin ($machinePath -split ";")) {
throw "Installer did not add $installDir to the machine PATH."
}
} finally {
if ($installed) {
$uninstall = Start-Process msiexec.exe `
-ArgumentList @("/x", "`"$env:MSI_PATH`"", "/qn", "/norestart") `
-Wait -PassThru
if ($uninstall.ExitCode -ne 0) {
throw "MSI uninstall failed with exit code $($uninstall.ExitCode)."
}
}
}
if (Test-Path $installedExe) {
throw "Installed executable remains after uninstall."
}

no-artifacts-tracked:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: No build artifacts tracked
runs-on: ubuntu-latest
steps:
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ jobs:
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256

- name: Verify Windows executable signature
if: runner.os == 'Windows'
shell: pwsh
run: |
$signature = Get-AuthenticodeSignature "pyinstaller-dist/dataplicity.exe"
if ($signature.Status -ne "Valid") {
throw "Executable signature is $($signature.Status): $($signature.StatusMessage)"
}
Write-Host "Executable signed by $($signature.SignerCertificate.Subject)"

- name: Build macOS tarball (for Homebrew)
if: runner.os == 'macOS'
shell: bash
Expand Down Expand Up @@ -136,6 +146,20 @@ jobs:
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256

- name: Verify Windows MSI signature
if: runner.os == 'Windows'
shell: pwsh
run: |
$msi = Get-ChildItem "dist/*.msi" | Select-Object -First 1
if (-not $msi) {
throw "No MSI found to verify."
}
$signature = Get-AuthenticodeSignature $msi.FullName
if ($signature.Status -ne "Valid") {
throw "MSI signature is $($signature.Status): $($signature.StatusMessage)"
}
Write-Host "MSI signed by $($signature.SignerCertificate.Subject)"

- name: Stage Windows release artifacts
if: runner.os == 'Windows'
shell: pwsh
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/update-winget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
fi

- name: Publish to WinGet
uses: vedantmgoyal9/winget-releaser@main
uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5
with:
identifier: Wildfoundry.DataplicityCLI
release-tag: ${{ github.event.release.tag_name || inputs.release_tag }}
Expand Down
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,26 @@ dataplicity --help

### Windows (no Python required)

Download the latest `.msi` from [GitHub Releases](https://github.com/wildfoundry/dataplicity-cli/releases) and install it. It installs `dataplicity.exe` and adds it to `PATH`.
Install the signed x64 MSI from WinGet in PowerShell or Windows Terminal:

```
```powershell
winget install --id Wildfoundry.DataplicityCLI --exact
dataplicity --help
```

WinGet handles future upgrades and uninstall:

```powershell
winget upgrade --id Wildfoundry.DataplicityCLI --exact
winget uninstall --id Wildfoundry.DataplicityCLI --exact
```

The installer is machine-wide and may request administrator approval. If
WinGet is unavailable, download the latest signed `.msi` from
[GitHub Releases](https://github.com/wildfoundry/dataplicity-cli/releases).
Both install paths add `dataplicity.exe` to `PATH`; open a new terminal after
installation.

### Python (developer install)

If you do have Python available and prefer `pipx`:
Expand Down Expand Up @@ -211,3 +225,4 @@ dataplicity --install-completion zsh
- The `Update WinGet package` workflow publishes new `.msi` releases to WinGet using `Wildfoundry.DataplicityCLI`.
- Configure a repository secret named `WINGET_TOKEN` (classic PAT with `public_repo`) and ensure your account has a fork of `microsoft/winget-pkgs`.
- WinGet automation updates existing manifests; if this package is not yet in WinGet, submit the first manifest for the current release, then subsequent releases are automated.
- Follow [`docs/windows-release.md`](docs/windows-release.md) before tagging a Windows release or submitting its first WinGet manifest.
2 changes: 1 addition & 1 deletion build/windows/DataplicityCLI.wxs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
InstallScope="perMachine" />

<MajorUpgrade DowngradeErrorMessage="A newer version of Dataplicity CLI is already installed." />
<MediaTemplate />
<MediaTemplate EmbedCab="yes" />

<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />

Expand Down
76 changes: 76 additions & 0 deletions build/winget/prepare_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from __future__ import annotations

import argparse
import re
import shutil
from pathlib import Path


def prepare_manifest(
*,
source_dir: Path,
output_root: Path,
version: str,
installer_url: str,
installer_sha256: str,
release_date: str,
) -> Path:
if not re.fullmatch(r"\d+\.\d+\.\d+(?:\.\d+)?", version):
raise ValueError("Version must be numeric, for example 0.1.7.")
if not re.fullmatch(r"[0-9A-Fa-f]{64}", installer_sha256):
raise ValueError("Installer SHA-256 must contain 64 hexadecimal characters.")
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", release_date):
raise ValueError("Release date must use YYYY-MM-DD.")

source_version = None
for manifest in source_dir.glob("*.yaml"):
match = re.search(r"^PackageVersion:\s*(\S+)\s*$", manifest.read_text(encoding="utf-8"), re.MULTILINE)
if match:
source_version = match.group(1)
break
if source_version is None:
raise ValueError(f"No PackageVersion found under {source_dir}.")

output_dir = output_root / version
if output_dir.exists():
shutil.rmtree(output_dir)
shutil.copytree(source_dir, output_dir)

for manifest in output_dir.glob("*.yaml"):
text = manifest.read_text(encoding="utf-8")
text = text.replace(source_version, version)
text = re.sub(r"(?m)^ InstallerUrl: .+$", f" InstallerUrl: {installer_url}", text)
text = re.sub(
r"(?m)^ InstallerSha256: .+$",
f" InstallerSha256: {installer_sha256.upper()}",
text,
)
text = re.sub(r"(?m)^ReleaseDate: .+$", f"ReleaseDate: {release_date}", text)
manifest.write_text(text, encoding="utf-8")

return output_dir


def main() -> None:
parser = argparse.ArgumentParser(description="Prepare a versioned WinGet manifest from the bootstrap template.")
parser.add_argument("--source-dir", type=Path, required=True)
parser.add_argument("--output-root", type=Path, required=True)
parser.add_argument("--version", required=True)
parser.add_argument("--installer-url", required=True)
parser.add_argument("--installer-sha256", required=True)
parser.add_argument("--release-date", required=True)
args = parser.parse_args()

output_dir = prepare_manifest(
source_dir=args.source_dir,
output_root=args.output_root,
version=args.version,
installer_url=args.installer_url,
installer_sha256=args.installer_sha256,
release_date=args.release_date,
)
print(output_dir)


if __name__ == "__main__":
main()
Loading
Loading