Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
190 changes: 190 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
name: release-build

on:
pull_request:
branches: [master]
workflow_dispatch:
inputs:
version:
description: 'Artifact version label (for example 1.0.1)'
required: false
default: '0.0.0-manual'

permissions:
contents: read

concurrency:
group: release-build-${{ github.ref }}
cancel-in-progress: true

jobs:
package:
name: ${{ matrix.rid }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
rid: win-x64
- os: ubuntu-latest
rid: linux-x64
- os: macos-14
rid: osx-arm64
- os: macos-15-intel
rid: osx-x64
runs-on: ${{ matrix.os }}
env:
DOTNET_NOLOGO: 'true'
DOTNET_CLI_TELEMETRY_OPTOUT: 'true'
PACKAGE_VERSION: ${{ inputs.version || format('0.0.0-ci.{0}', github.run_number) }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false

- name: Set up .NET 10
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: '10.0.x'

- name: Restore
run: dotnet restore DupeSweep.slnx

- name: Test (Release)
run: dotnet test DupeSweep.slnx --configuration Release --no-restore --verbosity normal

- name: Publish self-contained untrimmed single file
shell: pwsh
run: >-
dotnet publish src/DupeSweep/DupeSweep.csproj
--configuration Release
--runtime ${{ matrix.rid }}
--self-contained true
--output "${{ runner.temp }}/dsweep-publish-${{ matrix.rid }}"
-p:PublishSingleFile=true
-p:PublishTrimmed=false
-p:IncludeNativeLibrariesForSelfExtract=true
-p:DebugType=None

- name: Package archive and checksum
shell: pwsh
run: >-
./scripts/package-release.ps1
-PublishDirectory "${{ runner.temp }}/dsweep-publish-${{ matrix.rid }}"
-Rid "${{ matrix.rid }}"
-Version "$env:PACKAGE_VERSION"
-OutputDirectory "${{ runner.temp }}/dsweep-artifacts"

- name: Extract exact archive
shell: pwsh
run: >-
Expand-Archive
-LiteralPath "${{ runner.temp }}/dsweep-artifacts/dsweep-${env:PACKAGE_VERSION}-${{ matrix.rid }}.zip"
-DestinationPath "${{ runner.temp }}/dsweep-extracted-${{ matrix.rid }}"

- name: Record source commit receipt
shell: pwsh
run: |
$receipt = "${{ runner.temp }}/dsweep-artifacts/SOURCE-COMMIT-${{ matrix.rid }}.txt"
@(
"commit: $(git rev-parse HEAD)"
"rid: ${{ matrix.rid }}"
"runner: ${{ matrix.os }}"
"workflow: $env:GITHUB_WORKFLOW"
"run: $env:GITHUB_RUN_ID"
) | Set-Content -LiteralPath $receipt -Encoding ascii

- name: Verify native architecture
shell: pwsh
run: |
$binary = "${{ runner.temp }}/dsweep-extracted-${{ matrix.rid }}/$(if ('${{ matrix.rid }}' -like 'win-*') { 'dsweep.exe' } else { 'dsweep' })"
if (-not $IsWindows) {
& chmod +x $binary
if ($LASTEXITCODE -ne 0) { throw 'Could not mark the extracted Unix binary executable.' }
}
if ($IsWindows) {
$bytes = [IO.File]::ReadAllBytes($binary)
$peOffset = [BitConverter]::ToInt32($bytes, 0x3c)
if ($peOffset -lt 0 -or $peOffset + 6 -gt $bytes.Length) { throw 'Invalid PE header.' }
$machine = [BitConverter]::ToUInt16($bytes, $peOffset + 4)
if ($machine -ne 0x8664) { throw "Expected Windows x64 PE machine 0x8664, found 0x$($machine.ToString('x4'))." }
}
else {
$description = (& file $binary)
$expected = if ('${{ matrix.rid }}' -eq 'osx-arm64') { 'arm64' } elseif ('${{ matrix.rid }}' -eq 'osx-x64') { 'x86_64' } else { 'x86-64' }
if ($description -notmatch $expected) { throw "Expected $expected native binary, got: $description" }
}

- name: Run without an installed .NET runtime
shell: pwsh
env:
DOTNET_MULTILEVEL_LOOKUP: '0'
run: |
$emptyDotnetRoot = Join-Path $env:RUNNER_TEMP 'empty-dotnet-root'
New-Item -ItemType Directory -Path $emptyDotnetRoot -Force | Out-Null
$env:DOTNET_ROOT = $emptyDotnetRoot
$binary = "${{ runner.temp }}/dsweep-extracted-${{ matrix.rid }}/$(if ('${{ matrix.rid }}' -like 'win-*') { 'dsweep.exe' } else { 'dsweep' })"
& $binary --help *> (Join-Path $env:RUNNER_TEMP 'packaged-help.txt')
if ($LASTEXITCODE -ne 0) { throw "Self-contained packaged binary failed with exit code $LASTEXITCODE." }

- name: Smoke exact extracted binary
shell: pwsh
run: >-
./scripts/smoke-release.ps1
-BinaryPath "${{ runner.temp }}/dsweep-extracted-${{ matrix.rid }}/$(if ('${{ matrix.rid }}' -like 'win-*') { 'dsweep.exe' } else { 'dsweep' })"
-EvidenceDirectory "${{ runner.temp }}/dsweep-artifacts/evidence-${{ matrix.rid }}"

- name: Run deterministic demo against packaged binary
shell: pwsh
run: >-
./scripts/demo.ps1
-DsweepPath "${{ runner.temp }}/dsweep-extracted-${{ matrix.rid }}/$(if ('${{ matrix.rid }}' -like 'win-*') { 'dsweep.exe' } else { 'dsweep' })"
-OutputDirectory "${{ runner.temp }}/dsweep-artifacts/demo-${{ matrix.rid }}"

- name: Upload tested artifact and evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dsweep-${{ matrix.rid }}
path: ${{ runner.temp }}/dsweep-artifacts/*
if-no-files-found: error
retention-days: 14

checksums:
name: Aggregate SHA256SUMS
needs: package
runs-on: ubuntu-latest
steps:
- name: Download tested artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
pattern: dsweep-*
merge-multiple: true

- name: Verify and aggregate checksums
shell: pwsh
run: |
$archives = @(Get-ChildItem -LiteralPath . -Filter '*.zip' -File)
if ($archives.Count -ne 4) { throw "Expected four native archives, found $($archives.Count)." }
$checksumFiles = @(Get-ChildItem -LiteralPath . -Filter '*.zip.sha256' -File)
if ($checksumFiles.Count -ne 4) { throw "Expected four per-archive checksum files, found $($checksumFiles.Count)." }
$lines = foreach ($archive in ($archives | Sort-Object Name)) {
$hash = (Get-FileHash -LiteralPath $archive.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
$checksumPath = "$($archive.FullName).sha256"
if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) { throw "Missing checksum for $($archive.Name)." }
$expected = ((Get-Content -LiteralPath $checksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant()
if ($expected -cne $hash) { throw "Checksum mismatch for $($archive.Name)." }
"$hash $($archive.Name)"
}
$lines | Set-Content -LiteralPath SHA256SUMS -Encoding ascii
Get-Content -LiteralPath SHA256SUMS

- name: Upload aggregate checksums
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: dsweep-SHA256SUMS
path: SHA256SUMS
if-no-files-found: error
retention-days: 14
72 changes: 43 additions & 29 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,44 +1,58 @@
# Contributing to DupeSweep

Pull requests are welcome — bug fixes, new keep/apply strategies, better docs —
provided they keep the tone and quality of the codebase.
Bug fixes, tests, documentation, and focused improvements are welcome. Start with a
small, reviewable change and explain the behavior it adds or corrects.

## Ground rules
## Before you start

1. **One concern per pull request.** No drive-by refactors mixed with feature work.
2. **Branch from `master`**, keep the branch short, and squash-merge back.
3. **Conventional commits** (`feat:`, `fix:`, `perf:`, `refactor:`, `test:`, `docs:`, `chore:`).
4. **Green CI is non-negotiable.** `dotnet build` and `dotnet test` must pass before review.
5. **The PR template must be filled.** Empty checkboxes block review.
1. Search existing issues and pull requests, then describe your intended change in an issue when the scope is unclear.
2. Fork the repository and create a branch from `master`.
3. Keep one concern per pull request. Avoid drive-by refactors.
4. Use a Conventional Commit prefix such as `fix:`, `feat:`, `test:`, or `docs:`.

## Coding standards
For security vulnerabilities, do not open a public issue. Follow [SECURITY.md](SECURITY.md).

- **C# latest, nullable enabled.** No `#nullable disable`; resolve warnings, don't suppress them.
- **Intention-revealing names.** Full descriptive identifiers; `c`, `tmp`, `mgr` are rejected.
- **Comments explain *why*, never *what*.** No filler comments.
- **Async correctness.** Async methods take a `CancellationToken` and propagate it;
library-level awaits use `ConfigureAwait(false)` (the existing convention here).
- **SOLID / KISS / DRY / YAGNI.** One responsibility per type; the simplest correct solution wins.
- **File I/O is destructive by nature here.** Any change touching `QuarantineService`,
`RestoreService`, or the `--apply delete` path needs a test that proves data is never
silently lost (see the existing collision/skip tests for the pattern).
## Local checks

## Build, test, run
The project targets .NET 10. Run the same build and test commands used by CI:

```bash
dotnet build DupeSweep.slnx -c Release
dotnet test DupeSweep.slnx # unit + filesystem-backed tests
dotnet run --project src/DupeSweep -- --help # exercise the CLI
dotnet restore DupeSweep.slnx
dotnet build DupeSweep.slnx --configuration Release --no-restore
dotnet test DupeSweep.slnx --configuration Release --no-build
```

## Tests
To exercise the CLI help and the generated filesystem demo (PowerShell 7+ is required
for the `pwsh` command):

A pull request that ships behaviour without a test is sent back unless it is purely documentation.
```bash
dotnet run --project src/DupeSweep -- --help
pwsh -File ./scripts/demo.ps1
```

The demo creates a unique run directory and fixture below the requested output parent,
writes reports, hashes, logs, and a manifest copy there, and removes only that generated
fixture after the run. Do not point it at a directory containing user files.

## Code and test conventions

- Keep nullable reference types enabled and resolve warnings rather than suppressing them.
- Prefer descriptive names and comments that explain a design reason.
- Preserve async cancellation and the existing `ConfigureAwait(false)` convention in library code.
- Keep file-system tests inside their isolated temporary directory.
- Any change to quarantine, restore, or permanent delete must include a test showing that existing data is not silently lost.
- Do not add benchmarks or adoption claims without a reproducible method and published input data.

## Pull requests

Tests use a real, isolated temp directory per test (see `tests/DupeSweep.Tests/Support/TempDirectory.cs`)
and clean up after themselves. Do not add tests that touch paths outside their own temp directory.
The pull request description should state the problem, the resulting behavior, and the
checks run. Complete the repository pull request template. CI must pass on its supported
Ubuntu and Windows jobs; platform-specific behavior should include a focused test or a
clear explanation of why a test is not practical.

## Reporting bugs and proposing features
Pull requests build and test the release matrix, while publication happens only after
the merged source has been rebuilt and its tested archives and checksums reviewed.
Published executables are currently unsigned, so changes to packaging should preserve
the SHA-256 manifest and the documented source/build relationship.

Use the issue templates. For security vulnerabilities, **do not open a public issue** — follow
[`SECURITY.md`](SECURITY.md).
The [Code of Conduct](CODE_OF_CONDUCT.md) applies to all project spaces.
Loading