Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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:
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