diff --git a/.github/scripts/assert-cli-install.ps1 b/.github/scripts/assert-cli-install.ps1
new file mode 100644
index 0000000..5551422
--- /dev/null
+++ b/.github/scripts/assert-cli-install.ps1
@@ -0,0 +1,14 @@
+[CmdletBinding()]
+param([Parameter(Mandatory)][string] $Version)
+$ErrorActionPreference = 'Stop'
+$command = Get-Command syncsql -CommandType Application -ErrorAction Stop
+Write-Host "Installed command: $($command.Source)"
+$installedVersion = (& $command.Source --version | Out-String).Trim()
+if ($LASTEXITCODE -ne 0 -or $installedVersion -notmatch ('^' + [regex]::Escape($Version) + '(\+[^\s]+)?$')) {
+ throw "Expected syncsql $Version, got '$installedVersion'."
+}
+$help = & $command.Source --help | Out-String
+if ($LASTEXITCODE -ne 0 -or $help -notmatch 'validate-config' -or $help -notmatch 'catalog') {
+ throw 'The installed CLI did not return its expected help.'
+}
+Write-Host $help
diff --git a/.github/scripts/nuget-release.cjs b/.github/scripts/nuget-release.cjs
index fd829c6..7cc54d6 100644
--- a/.github/scripts/nuget-release.cjs
+++ b/.github/scripts/nuget-release.cjs
@@ -24,9 +24,13 @@ async function reserveTag(options) {
const { github, context, tag } = options;
requireMainPush(context);
if (!await findTag(options)) {
- await github.rest.git.createRef({
- ...context.repo, ref: `refs/tags/${tag}`, sha: context.sha
- });
+ try {
+ await github.rest.git.createRef({
+ ...context.repo, ref: `refs/tags/${tag}`, sha: context.sha
+ });
+ } catch (error) {
+ if (error.status !== 422 || !await findTag(options)) throw error;
+ }
}
}
@@ -34,31 +38,38 @@ async function publishRelease(options) {
const { github, context, tag, packageId, version, artifacts = 'artifacts' } = options;
requireMainPush(context);
if (!await findTag(options)) throw new Error('The release tag must be reserved before publishing.');
- const name = `${packageId}.${version}.nupkg`;
- const data = fs.readFileSync(path.join(artifacts, name));
+ const filenames = options.filenames || [`${packageId}.${version}.nupkg`];
let release;
try {
({ data: release } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }));
} catch (error) {
if (error.status !== 404) throw error;
- ({ data: release } = await github.rest.repos.createRelease({
- ...context.repo, tag_name: tag, target_commitish: context.sha,
- name: `${packageId} ${version}`, draft: true, prerelease: false,
- generate_release_notes: true
- }));
+ try {
+ ({ data: release } = await github.rest.repos.createRelease({
+ ...context.repo, tag_name: tag, target_commitish: context.sha,
+ name: `${packageId} ${version}`, draft: true, prerelease: false,
+ generate_release_notes: true
+ }));
+ } catch (createError) {
+ if (createError.status !== 422) throw createError;
+ ({ data: release } = await github.rest.repos.getReleaseByTag({ ...context.repo, tag }));
+ }
}
const assets = await github.paginate(github.rest.repos.listReleaseAssets, {
...context.repo, release_id: release.id, per_page: 100
});
- const asset = assets.find(item => item.name === name);
- if (asset && asset.state !== 'uploaded') {
- await github.rest.repos.deleteReleaseAsset({ ...context.repo, asset_id: asset.id });
- }
- if (!asset || asset.state !== 'uploaded') {
- await github.rest.repos.uploadReleaseAsset({
- ...context.repo, release_id: release.id, name, data,
- headers: { 'content-type': 'application/octet-stream', 'content-length': data.length }
- });
+ for (const name of filenames) {
+ const data = fs.readFileSync(path.join(artifacts, name));
+ const asset = assets.find(item => item.name === name);
+ if (asset && asset.state !== 'uploaded') {
+ await github.rest.repos.deleteReleaseAsset({ ...context.repo, asset_id: asset.id });
+ }
+ if (!asset || asset.state !== 'uploaded') {
+ await github.rest.repos.uploadReleaseAsset({
+ ...context.repo, release_id: release.id, name, data,
+ headers: { 'content-type': 'application/octet-stream', 'content-length': data.length }
+ });
+ }
}
if (release.draft) {
await github.rest.repos.updateRelease({
diff --git a/.github/scripts/nuget-release.test.cjs b/.github/scripts/nuget-release.test.cjs
index 14f17df..46a4e2c 100644
--- a/.github/scripts/nuget-release.test.cjs
+++ b/.github/scripts/nuget-release.test.cjs
@@ -120,3 +120,31 @@ test('replaces an incomplete asset before publishing a recovered draft', async (
await publishRelease(f);
assert.deepEqual(f.calls.map(([name]) => name), ['tag', 'delete', 'upload', 'publish']);
});
+
+test('concurrent publishers reuse a tag and release created by their peer', async () => {
+ const f = fixture();
+ f.github.rest.git.createRef = async args => {
+ f.state.tag = { object: { type: 'commit', sha: args.sha } };
+ throw Object.assign(new Error('Already exists'), { status: 422 });
+ };
+ f.github.rest.repos.createRelease = async () => {
+ f.state.release = { id: 7, draft: false };
+ throw Object.assign(new Error('Already exists'), { status: 422 });
+ };
+ await reserveTag(f);
+ await publishRelease(f);
+ assert.deepEqual(f.calls.map(([name]) => name), ['upload']);
+});
+
+test('uploads Windows assets alongside the existing NuGet asset, idempotently', async () => {
+ const f = fixture();
+ await reserveTag(f);
+ await publishRelease(f);
+ const filenames = ['syncsql-2026.9.26.42-win-x64.zip', 'syncsql.2026.9.26.42.nupkg', 'syncsql-2026.9.26.42-win-x64.zip.sha256'];
+ for (const name of filenames) fs.writeFileSync(path.join(artifacts, name), name);
+ await publishRelease({ ...f, filenames });
+ assert.equal(f.state.assets.length, 4);
+ const calls = f.calls.length;
+ await publishRelease({ ...f, filenames });
+ assert.equal(f.calls.length, calls);
+});
diff --git a/.github/scripts/prepare-winget.ps1 b/.github/scripts/prepare-winget.ps1
new file mode 100644
index 0000000..9a7a4d3
--- /dev/null
+++ b/.github/scripts/prepare-winget.ps1
@@ -0,0 +1,12 @@
+$ErrorActionPreference = 'Stop'
+$env:PATH = (Join-Path $env:LOCALAPPDATA 'Microsoft/WindowsApps') + [IO.Path]::PathSeparator + $env:PATH
+if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
+ Install-Module -Name Microsoft.WinGet.Client -Repository PSGallery -Scope CurrentUser -Force
+ Import-Module Microsoft.WinGet.Client
+ Repair-WinGetPackageManager -AllUsers
+}
+winget --version
+if ($LASTEXITCODE) { throw 'WinGet bootstrap failed.' }
+Join-Path $env:LOCALAPPDATA 'Microsoft/WindowsApps' >> $env:GITHUB_PATH
+# Portable commands are added to the next workflow step's PATH.
+Join-Path $env:LOCALAPPDATA 'Microsoft/WinGet/Links' >> $env:GITHUB_PATH
diff --git a/.github/scripts/windows-package.cjs b/.github/scripts/windows-package.cjs
new file mode 100644
index 0000000..3c399af
--- /dev/null
+++ b/.github/scripts/windows-package.cjs
@@ -0,0 +1,73 @@
+const fs = require('node:fs');
+const path = require('node:path');
+const crypto = require('node:crypto');
+
+function generate(version, archive, output) {
+ const match = /^(\d{4})\.([1-9]|1[0-2])\.([1-9]|[12]\d|3[01])(?:\.([1-9]\d{0,4}))?$/.exec(version);
+ if (!match || Number(match[4] || 0) > 65534 || Number(match[3]) > new Date(Date.UTC(Number(match[1]), Number(match[2]), 0)).getUTCDate()) {
+ throw new Error('Windows packages require a stable CalVer YYYY.M.D[.REV].');
+ }
+ const filename = `syncsql-${version}-win-x64.zip`;
+ if (path.basename(archive) !== filename) throw new Error(`Expected archive ${filename}`);
+ const hash = crypto.createHash('sha256').update(fs.readFileSync(archive)).digest('hex').toUpperCase();
+ const home = 'https://github.com/cangelosilima/SyncSQL';
+ const url = `${home}/releases/download/cli-v${version}/${filename}`;
+ const write = (name, content) => {
+ const target = path.join(output, name);
+ fs.mkdirSync(path.dirname(target), { recursive: true });
+ fs.writeFileSync(target, content);
+ };
+ write('chocolatey/syncsql.nuspec', `
+
+
+ syncsql${version}
+ SQLineage CLI (syncsql)cangelosilima
+ ${home}${home}
+ false
+ SQL Server and Oracle extraction, lineage and catalog CLI.
+ Self-contained Windows x64 CLI for database extraction, lineage analysis and catalog building. No separate .NET runtime or native Oracle client is required.
+ sql database lineage oracle cli
+
+
+
+`);
+ write('chocolatey/tools/chocolateyinstall.ps1', `$ErrorActionPreference = 'Stop'
+if (-not [Environment]::Is64BitOperatingSystem -or $env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { throw 'syncsql requires Windows x64.' }
+$toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
+Install-ChocolateyZipPackage -PackageName 'syncsql' -Url64bit '${url}' -UnzipLocation $toolsDir -Checksum64 '${hash}' -ChecksumType64 'sha256'
+# Only expose the CLI, not other executables shipped by the runtime.
+Get-ChildItem $toolsDir -Filter *.exe -Recurse | Where-Object Name -ne 'syncsql.exe' | ForEach-Object { New-Item -ItemType File -Path ($_.FullName + '.ignore') -Force | Out-Null }
+`);
+ const prefix = `PackageIdentifier: cangelosilima.SyncSQL\nPackageVersion: ${version}\n`;
+ const dir = `winget/manifests/c/cangelosilima/SyncSQL/${version}`;
+ write(`${dir}/cangelosilima.SyncSQL.yaml`, `${prefix}DefaultLocale: en-US\nManifestType: version\nManifestVersion: 1.6.0\n`);
+ write(`${dir}/cangelosilima.SyncSQL.locale.en-US.yaml`, `${prefix}PackageLocale: en-US
+Publisher: cangelosilima
+PackageName: SQLineage CLI
+PackageUrl: ${home}
+License: Not specified
+ShortDescription: SQL Server and Oracle extraction, lineage and catalog CLI.
+Moniker: syncsql
+ManifestType: defaultLocale
+ManifestVersion: 1.6.0
+`);
+ write(`${dir}/cangelosilima.SyncSQL.installer.yaml`, `${prefix}InstallerType: zip
+NestedInstallerType: portable
+NestedInstallerFiles:
+ - RelativeFilePath: syncsql.exe
+ PortableCommandAlias: syncsql
+Installers:
+ - Architecture: x64
+ InstallerUrl: ${url}
+ InstallerSha256: ${hash}
+ManifestType: installer
+ManifestVersion: 1.6.0
+`);
+ write(`${filename}.sha256`, `${hash} ${filename}\n`);
+}
+
+module.exports = { generate };
+if (require.main === module) {
+ if (process.argv.length !== 5) throw new Error('Usage: node windows-package.cjs VERSION ARCHIVE OUTPUT');
+ generate(...process.argv.slice(2));
+}
diff --git a/.github/scripts/windows-package.test.cjs b/.github/scripts/windows-package.test.cjs
new file mode 100644
index 0000000..5bcb05f
--- /dev/null
+++ b/.github/scripts/windows-package.test.cjs
@@ -0,0 +1,33 @@
+const { test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const crypto = require('node:crypto');
+const { generate } = require('./windows-package.cjs');
+
+test('both package managers consume the same release URL and archive checksum', (t) => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'syncsql-packaging-'));
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
+ const archive = path.join(dir, 'syncsql-2026.10.3.1-win-x64.zip');
+ fs.writeFileSync(archive, 'fixture archive');
+ const output = path.join(dir, 'output');
+ generate('2026.10.3.1', archive, output);
+ const hash = crypto.createHash('sha256').update('fixture archive').digest('hex').toUpperCase();
+ const install = fs.readFileSync(path.join(output, 'chocolatey/tools/chocolateyinstall.ps1'), 'utf8');
+ const manifest = fs.readFileSync(path.join(output, 'winget/manifests/c/cangelosilima/SyncSQL/2026.10.3.1/cangelosilima.SyncSQL.installer.yaml'), 'utf8');
+ for (const content of [install, manifest]) {
+ assert.ok(content.includes(hash));
+ assert.ok(content.includes('https://github.com/cangelosilima/SyncSQL/releases/download/cli-v2026.10.3.1/syncsql-2026.10.3.1-win-x64.zip'));
+ }
+ assert.match(manifest, /RelativeFilePath: syncsql.exe/);
+ assert.match(manifest, /PortableCommandAlias: syncsql/);
+ assert.match(install, /ChecksumType64 'sha256'/);
+});
+
+test('rejects prereleases, invalid dates, unsafe versions and mismatched assets', () => {
+ for (const version of ['2026.2.29', '2026.04.3', '2026.10.3-rc.1', '2026.10.3.65535', '../1', '2026.13.1']) {
+ assert.throws(() => generate(version, 'missing.zip', 'unused'), /stable CalVer/);
+ }
+ assert.throws(() => generate('2026.10.3', 'wrong.zip', 'unused'), /Expected archive/);
+});
diff --git a/.github/workflows/cli-publish-nuget.yml b/.github/workflows/cli-publish-nuget.yml
index b8820e3..265a81b 100644
--- a/.github/workflows/cli-publish-nuget.yml
+++ b/.github/workflows/cli-publish-nuget.yml
@@ -9,6 +9,10 @@ on:
- 'global.json'
- '.github/workflows/cli-publish-nuget.yml'
- '.github/scripts/nuget-release*.cjs'
+ - '.github/scripts/windows-package*.cjs'
+ - '.github/workflows/cli-publish-windows.yml'
+ - '.github/scripts/*-cli-install.ps1'
+ - '.github/scripts/prepare-winget.ps1'
pull_request:
paths:
- 'cli/**'
@@ -16,6 +20,10 @@ on:
- 'global.json'
- '.github/workflows/cli-publish-nuget.yml'
- '.github/scripts/nuget-release*.cjs'
+ - '.github/scripts/windows-package*.cjs'
+ - '.github/workflows/cli-publish-windows.yml'
+ - '.github/scripts/*-cli-install.ps1'
+ - '.github/scripts/prepare-winget.ps1'
workflow_dispatch:
permissions:
@@ -120,3 +128,68 @@ jobs:
github, context, tag: process.env.RELEASE_TAG,
packageId: process.env.PACKAGE_ID, version: process.env.PACKAGE_VERSION
});
+
+ windows:
+ needs: package
+ uses: ./.github/workflows/cli-publish-windows.yml
+ permissions:
+ contents: write
+ secrets: inherit
+ with:
+ version: ${{ needs.package.outputs.version }}
+ tag: ${{ needs.package.outputs.tag }}
+ commit: ${{ github.sha }}
+ publish: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
+
+ verify-nuget-install:
+ needs: [package, publish]
+ runs-on: ${{ matrix.os }}
+ timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest, macos-latest]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+ - name: Wait for the published NuGet version
+ shell: pwsh
+ env:
+ VERSION: ${{ needs.package.outputs.version }}
+ run: |
+ $url = "https://api.nuget.org/v3-flatcontainer/syncsql.cli/$env:VERSION/syncsql.cli.$env:VERSION.nupkg"
+ $visible = $false
+ for ($attempt = 1; $attempt -le 20; $attempt++) {
+ try {
+ Invoke-WebRequest $url -Method Head -TimeoutSec 30 | Out-Null
+ $visible = $true
+ break
+ } catch {
+ Write-Host "NuGet version not available yet (attempt $attempt/20)."
+ if ($attempt -lt 20) { Start-Sleep -Seconds 30 }
+ }
+ }
+ if (-not $visible) { throw 'Published package did not become available on nuget.org.' }
+ - name: Install from nuget.org
+ shell: pwsh
+ env:
+ VERSION: ${{ needs.package.outputs.version }}
+ run: |
+ $config = Join-Path $env:RUNNER_TEMP 'install-nuget.config'
+ '' | Set-Content $config
+ dotnet tool install --global SyncSql.Cli --version $env:VERSION --configfile $config --no-cache
+ if ($LASTEXITCODE) { throw 'Published dotnet tool installation failed.' }
+ Join-Path ([Environment]::GetFolderPath('UserProfile')) '.dotnet/tools' >> $env:GITHUB_PATH
+ - name: Verify installed command and version
+ shell: pwsh
+ env:
+ VERSION: ${{ needs.package.outputs.version }}
+ run: ./.github/scripts/assert-cli-install.ps1 -Version $env:VERSION
+ - name: Uninstall the tool
+ shell: pwsh
+ run: |
+ dotnet tool uninstall --global SyncSql.Cli
+ if ($LASTEXITCODE) { throw 'dotnet tool uninstall failed.' }
+ if (Get-Command syncsql -CommandType Application -ErrorAction SilentlyContinue) { throw 'syncsql is still on PATH after uninstall.' }
diff --git a/.github/workflows/cli-publish-windows.yml b/.github/workflows/cli-publish-windows.yml
new file mode 100644
index 0000000..5523eb1
--- /dev/null
+++ b/.github/workflows/cli-publish-windows.yml
@@ -0,0 +1,166 @@
+name: Publish Windows CLI packages
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ type: string
+ required: true
+ tag:
+ type: string
+ required: true
+ commit:
+ type: string
+ required: true
+ publish:
+ type: boolean
+ required: true
+permissions:
+ contents: read
+
+concurrency:
+ group: windows-cli-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ package:
+ runs-on: windows-latest
+ timeout-minutes: 30
+ outputs:
+ version: ${{ steps.version.outputs.version }}
+ tag: ${{ inputs.tag }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.commit }}
+ - uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+ - uses: actions/setup-node@v4
+ with:
+ node-version: '22'
+ - name: Use the caller version
+ id: version
+ shell: pwsh
+ env:
+ CALLER_VERSION: ${{ inputs.version }}
+ run: |
+ # Validation NuGet versions have a CI suffix; Windows manifests use stable CalVer.
+ $version = $env:CALLER_VERSION.Split('-')[0]
+ "version=$version" >> $env:GITHUB_OUTPUT
+ - name: Test package generation
+ run: node --test .github/scripts/windows-package.test.cjs
+ - name: Publish self-contained CLI
+ shell: pwsh
+ env:
+ VERSION: ${{ steps.version.outputs.version }}
+ run: |
+ dotnet publish cli/src/SyncSql.Cli/SyncSql.Cli.csproj -c Release -r win-x64 --self-contained true -p:WindowsStandalone=true -p:Version=$env:VERSION -p:ContinuousIntegrationBuild=true -o artifacts/publish
+ if ($LASTEXITCODE) { throw 'dotnet publish failed' }
+ New-Item artifacts/publish/licenses/plsql -ItemType Directory -Force | Out-Null
+ Copy-Item grammar/src/SyncSql.Grammar.PlSql/Grammar/NOTICE.md, grammar/src/SyncSql.Grammar.PlSql/Grammar/LICENSE.txt artifacts/publish/licenses/plsql
+ & artifacts/publish/syncsql.exe --help
+ if ($LASTEXITCODE) { throw 'CLI smoke test failed' }
+ Compress-Archive artifacts/publish/* "artifacts/syncsql-$env:VERSION-win-x64.zip"
+ node .github/scripts/windows-package.cjs $env:VERSION "artifacts/syncsql-$env:VERSION-win-x64.zip" artifacts/packages
+ if ($LASTEXITCODE) { throw 'Manifest generation failed' }
+ - name: Pack Chocolatey package
+ shell: pwsh
+ run: |
+ choco pack artifacts/packages/chocolatey/syncsql.nuspec --outputdirectory artifacts
+ if ($LASTEXITCODE) { throw 'Chocolatey pack failed' }
+ - uses: actions/upload-artifact@v4
+ with:
+ name: syncsql-windows
+ path: |
+ artifacts/*.zip
+ artifacts/*.nupkg
+ artifacts/packages
+ if-no-files-found: error
+
+ release:
+ needs: package
+ if: inputs.publish && github.event_name == 'push' && github.ref == 'refs/heads/main'
+ runs-on: windows-latest
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.commit }}
+ - uses: actions/download-artifact@v4
+ with:
+ name: syncsql-windows
+ path: artifacts
+ - name: Prepare checksum asset
+ shell: pwsh
+ run: Copy-Item artifacts/packages/*.sha256 artifacts
+ - name: Publish release assets
+ uses: actions/github-script@v7
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
+ PACKAGE_VERSION: ${{ needs.package.outputs.version }}
+ with:
+ script: |
+ const { reserveTag, publishRelease } = require('./.github/scripts/nuget-release.cjs');
+ const version = process.env.PACKAGE_VERSION;
+ await reserveTag({ github, context, tag: process.env.RELEASE_TAG });
+ await publishRelease({
+ github, context, tag: process.env.RELEASE_TAG,
+ packageId: 'SyncSql.Cli', version,
+ filenames: [
+ `syncsql-${version}-win-x64.zip`,
+ `syncsql.${version}.nupkg`,
+ `syncsql-${version}-win-x64.zip.sha256`
+ ]
+ });
+
+ chocolatey:
+ needs: [package, release]
+ runs-on: windows-latest
+ environment: windows-packages
+ timeout-minutes: 10
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: syncsql-windows
+ path: artifacts
+ - name: Submit to Chocolatey
+ shell: pwsh
+ env:
+ CHOCO_API_KEY: ${{ secrets.CHOCO_API_KEY }}
+ run: |
+ if (-not $env:CHOCO_API_KEY) { throw 'Configure CHOCO_API_KEY in the windows-packages environment.' }
+ choco push "artifacts/syncsql.${{ needs.package.outputs.version }}.nupkg" --source https://push.chocolatey.org/ --api-key $env:CHOCO_API_KEY
+ if ($LASTEXITCODE) { throw 'Chocolatey submission failed' }
+
+ winget:
+ needs: [package, release]
+ runs-on: windows-latest
+ environment: windows-packages
+ timeout-minutes: 10
+ steps:
+ - uses: actions/download-artifact@v4
+ with:
+ name: syncsql-windows
+ path: artifacts
+ - uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: '6.0.x'
+ - name: Submit WinGet manifests
+ shell: pwsh
+ env:
+ WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
+ run: |
+ if (-not $env:WINGET_TOKEN) { throw 'Configure WINGET_TOKEN in the windows-packages environment.' }
+ Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
+ ./wingetcreate.exe submit artifacts/packages/winget/manifests/c/cangelosilima/SyncSQL/${{ needs.package.outputs.version }} --token $env:WINGET_TOKEN
+ if ($LASTEXITCODE) { throw 'WinGet submission failed' }
+
+ verify-community-install:
+ needs: [package, chocolatey, winget]
+ uses: ./.github/workflows/cli-verify-community-install.yml
+ with:
+ version: ${{ needs.package.outputs.version }}
+ commit: ${{ inputs.commit }}
\ No newline at end of file
diff --git a/.github/workflows/cli-verify-community-install.yml b/.github/workflows/cli-verify-community-install.yml
new file mode 100644
index 0000000..3f691f3
--- /dev/null
+++ b/.github/workflows/cli-verify-community-install.yml
@@ -0,0 +1,94 @@
+name: Verify public Windows package installation
+
+on:
+ workflow_call:
+ inputs:
+ version:
+ type: string
+ required: true
+ commit:
+ type: string
+ required: true
+ workflow_dispatch:
+ inputs:
+ version:
+ description: Published stable CLI version accepted into both community catalogs
+ type: string
+ required: true
+
+permissions:
+ contents: read
+
+jobs:
+ install:
+ runs-on: windows-latest
+ timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ manager: [chocolatey, winget]
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.commit || github.sha }}
+ - name: Validate requested version
+ shell: pwsh
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ if ($env:VERSION -notmatch '^[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}(\.[0-9]{1,5})?$') {
+ throw 'Specify a stable CalVer YYYY.M.D[.REV].'
+ }
+ - name: Install from the Chocolatey community catalog
+ if: matrix.manager == 'chocolatey'
+ shell: pwsh
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ $installed = $false
+ for ($attempt = 1; $attempt -le 20; $attempt++) {
+ choco install syncsql --version $env:VERSION --source https://community.chocolatey.org/api/v2/ --yes --no-progress
+ if ($LASTEXITCODE -eq 0) { $installed = $true; break }
+ Write-Host "Chocolatey public installation failed (attempt $attempt/20); awaiting moderation or retrying a transient failure."
+ if ($attempt -lt 20) { Start-Sleep -Seconds 30 }
+ }
+ if (-not $installed) { throw 'Exact-version installation from Chocolatey failed. Once moderation completes, rerun this verification job.' }
+ Join-Path $env:ChocolateyInstall 'bin' >> $env:GITHUB_PATH
+ - name: Prepare WinGet
+ if: matrix.manager == 'winget'
+ shell: pwsh
+ run: ./.github/scripts/prepare-winget.ps1
+ - name: Install from the WinGet community catalog
+ if: matrix.manager == 'winget'
+ shell: pwsh
+ env:
+ VERSION: ${{ inputs.version }}
+ run: |
+ $installed = $false
+ for ($attempt = 1; $attempt -le 20; $attempt++) {
+ winget source update --name winget --disable-interactivity
+ if ($LASTEXITCODE -eq 0) {
+ winget install --id cangelosilima.SyncSQL --exact --version $env:VERSION --source winget --scope user --accept-package-agreements --accept-source-agreements --disable-interactivity
+ if ($LASTEXITCODE -eq 0) { $installed = $true; break }
+ }
+ Write-Host "WinGet public installation failed (attempt $attempt/20); awaiting catalog approval/indexing or retrying a transient failure."
+ if ($attempt -lt 20) { Start-Sleep -Seconds 30 }
+ }
+ if (-not $installed) { throw 'Exact-version installation from WinGet failed. Once the catalog contains this version, rerun this verification job.' }
+ - name: Verify installed command and version
+ shell: pwsh
+ env:
+ VERSION: ${{ inputs.version }}
+ run: ./.github/scripts/assert-cli-install.ps1 -Version $env:VERSION
+ - name: Uninstall and check removal
+ shell: pwsh
+ env:
+ MANAGER: ${{ matrix.manager }}
+ run: |
+ if ($env:MANAGER -eq 'chocolatey') {
+ choco uninstall syncsql --yes --no-progress
+ } else {
+ winget uninstall --id cangelosilima.SyncSQL --exact --disable-interactivity
+ }
+ if ($LASTEXITCODE) { throw 'Package uninstall failed.' }
+ if (Get-Command syncsql -CommandType Application -ErrorAction SilentlyContinue) { throw 'syncsql is still on PATH after uninstall.' }
diff --git a/README.md b/README.md
index d5d8844..5133f33 100644
--- a/README.md
+++ b/README.md
@@ -136,6 +136,9 @@ in `site/src/help/` and are bundled with the site.

+CLI installation: [.NET global tool](cli/docs/cli.md#install) or
+[Windows Chocolatey / WinGet release flow](docs/windows-packaging.md).
+
## How it works
```mermaid
diff --git a/cli/docs/cli.md b/cli/docs/cli.md
index af3bf25..f70339b 100644
--- a/cli/docs/cli.md
+++ b/cli/docs/cli.md
@@ -42,6 +42,10 @@ Windows authentication can instead use the identity running the CLI.
## Install
+Windows x64 releases also have a Chocolatey and WinGet packaging flow with
+the .NET runtime included. See [Windows package releases](../../docs/windows-packaging.md)
+for installation commands, registry availability and maintainer setup.
+
`syncsql` is published as a [dotnet global tool](https://learn.microsoft.com/dotnet/core/tools/global-tools).
```bash
diff --git a/cli/src/SyncSql.Cli/SyncSql.Cli.csproj b/cli/src/SyncSql.Cli/SyncSql.Cli.csproj
index 66b6614..7f6601e 100644
--- a/cli/src/SyncSql.Cli/SyncSql.Cli.csproj
+++ b/cli/src/SyncSql.Cli/SyncSql.Cli.csproj
@@ -1,4 +1,4 @@
-
+
@@ -44,4 +44,9 @@
+
+ false
+ syncsql
+
+
diff --git a/docs/nuget-publishing.md b/docs/nuget-publishing.md
index 53b87a3..f5af323 100644
--- a/docs/nuget-publishing.md
+++ b/docs/nuget-publishing.md
@@ -76,6 +76,11 @@ Pull requests and manual runs only validate, with versions suffixed
option. GitHub releases for the two packages do not replace each other as the
repository's latest release.
+The CLI workflow also calls the reusable Windows packaging workflow alongside
+its NuGet publish job. Chocolatey and WinGet use the CLI version, release tag
+and tested commit. See [Windows package releases](windows-packaging.md) for
+registry credentials, validation and installation commands.
+
## Consume
```bash
diff --git a/docs/windows-packaging.md b/docs/windows-packaging.md
new file mode 100644
index 0000000..be2e08a
--- /dev/null
+++ b/docs/windows-packaging.md
@@ -0,0 +1,94 @@
+# Windows package releases
+
+`cli-publish-windows.yml` builds a self-contained Windows x64 CLI, runs
+`syncsql --help`, packages it as a ZIP, and generates Chocolatey `syncsql`
+and WinGet `cangelosilima.SyncSQL` metadata using that ZIP's SHA-256.
+The .NET runtime is included. The PL/SQL grammar license and notice travel
+with the executable. The repository currently has no application license;
+the WinGet metadata says `Not specified`. Decide and document the application
+license before the first community submission; do not claim an open-source
+license merely because the repository is public.
+
+## Setup and release
+
+1. Create the GitHub Actions environment `windows-packages`. Add
+ `CHOCO_API_KEY` for the Chocolatey account owning `syncsql`, and
+ `WINGET_TOKEN` for a GitHub account able to fork and submit pull requests
+ to `microsoft/winget-pkgs`. The default Actions token cannot do this.
+ Confirm the proposed package identifiers are available before first release.
+2. Merge relevant changes into protected `main`. The CLI NuGet workflow calls
+ the reusable Windows workflow alongside its NuGet publish job. Both consume
+ the same `YYYY.M.D.BUILD` version, `cli-v*` tag and tested commit.
+3. Windows packaging uploads the ZIP, Chocolatey package and checksum to the
+ CLI GitHub release, then submits to each registry independently. Configure
+ environment reviewers if release submissions should require review.
+4. Wait for Chocolatey moderation and the WinGet pull request to be accepted.
+ Submission alone does not make the package searchable or installable from
+ the public catalogs. Monitor the submission outputs for the review links.
+
+Pull requests and manual runs of the CLI NuGet workflow validate both formats
+without publishing. Only a push to `main` may publish; protect that branch with
+required reviews and checks so its pushes come from approved merges. Tag pushes
+cannot publish. Release assets are immutable: merge a new PR for changed
+binaries. If a registry submission fails after assets are uploaded, rerun only
+the failed registry job; do not rebuild the release.
+
+## Install after registry acceptance
+
+After the NuGet publish job succeeds, three fresh runners (Linux, Windows and
+macOS) install that exact version with `dotnet tool install --global` from
+nuget.org. The check waits up to ten minutes for NuGet propagation, then runs
+`syncsql --version`, `syncsql --help`, and uninstall, checking command removal.
+
+After Chocolatey and WinGet submissions succeed, two separate Windows runners
+install the exact published version directly from their public catalogs:
+Chocolatey uses `https://community.chocolatey.org/api/v2/`; WinGet uses
+`--source winget` with the package ID and version. No local feeds, local
+manifests or release-asset fallback are used by these installation checks.
+Each checks the installed version, command discovery on PATH, help, and removal.
+
+Windows catalog installation retries up to twenty times, thirty seconds apart.
+Community moderation may take longer than this window. If the exact version
+remains unavailable, the verification job fails with a message to rerun after
+approval; registry submission jobs remain successful. No older version is used
+to make the test pass.
+
+Once both community catalogs accept a version, run **Verify public Windows
+package installation** (`cli-verify-community-install.yml`) with that version.
+It installs directly from the public Chocolatey and WinGet catalogs on two
+fresh runners, verifies the version and help, and tests uninstall. No workflow
+publishes from this manual verification run. The release workflow also calls
+this same verification workflow automatically after registry submission.
+
+```powershell
+choco install syncsql -y
+choco upgrade syncsql -y
+choco uninstall syncsql -y
+
+winget install --id cangelosilima.SyncSQL --exact --source winget
+winget upgrade --id cangelosilima.SyncSQL --exact --source winget
+winget uninstall --id cangelosilima.SyncSQL --exact
+
+syncsql --help
+```
+
+Use one installation method per machine to avoid competing `syncsql` commands
+on PATH. Other operating systems can use the existing .NET global tool.
+
+## Local validation
+
+```powershell
+node --test .github/scripts/windows-package.test.cjs
+dotnet publish cli/src/SyncSql.Cli/SyncSql.Cli.csproj -c Release -r win-x64 --self-contained true -p:WindowsStandalone=true -p:Version=2026.10.3 -o artifacts/windows/publish
+Compress-Archive artifacts/windows/publish/* artifacts/windows/syncsql-2026.10.3-win-x64.zip
+node .github/scripts/windows-package.cjs 2026.10.3 artifacts/windows/syncsql-2026.10.3-win-x64.zip artifacts/windows/packages
+choco pack artifacts/windows/packages/chocolatey/syncsql.nuspec --outputdirectory artifacts/windows
+winget validate artifacts/windows/packages/winget/manifests/c/cangelosilima/SyncSQL/2026.10.3
+```
+
+Before marking a release verified, require its public-source installation jobs
+to pass. Local packaging and manifest validation alone do not verify that the
+release is installable from the public catalogs.
+
+The manifests follow [WinGet's manifest format](https://learn.microsoft.com/windows/package-manager/package/manifest)
+and submissions use [Microsoft's WingetCreate](https://github.com/microsoft/winget-create).
diff --git a/site/package-lock.json b/site/package-lock.json
index 48a3a57..92403d9 100644
--- a/site/package-lock.json
+++ b/site/package-lock.json
@@ -1381,9 +1381,9 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4275,9 +4275,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4861,9 +4861,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
- "version": "1.1.18",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
- "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -5744,9 +5744,9 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "5.0.9",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
- "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7806,9 +7806,9 @@
}
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
- "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
+ "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"