Skip to content
Merged
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
14 changes: 14 additions & 0 deletions .github/scripts/assert-cli-install.ps1
Original file line number Diff line number Diff line change
@@ -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
49 changes: 30 additions & 19 deletions .github/scripts/nuget-release.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,41 +24,52 @@ 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;
}
}
}

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({
Expand Down
28 changes: 28 additions & 0 deletions .github/scripts/nuget-release.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
12 changes: 12 additions & 0 deletions .github/scripts/prepare-winget.ps1
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions .github/scripts/windows-package.cjs
Original file line number Diff line number Diff line change
@@ -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', `<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
<metadata>
<id>syncsql</id><version>${version}</version>
<title>SQLineage CLI (syncsql)</title><authors>cangelosilima</authors>
<projectUrl>${home}</projectUrl><packageSourceUrl>${home}</packageSourceUrl>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<summary>SQL Server and Oracle extraction, lineage and catalog CLI.</summary>
<description>Self-contained Windows x64 CLI for database extraction, lineage analysis and catalog building. No separate .NET runtime or native Oracle client is required.</description>
<tags>sql database lineage oracle cli</tags>
</metadata>
<files><file src="tools\\**" target="tools" /></files>
</package>
`);
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));
}
33 changes: 33 additions & 0 deletions .github/scripts/windows-package.test.cjs
Original file line number Diff line number Diff line change
@@ -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/);
});
73 changes: 73 additions & 0 deletions .github/workflows/cli-publish-nuget.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,21 @@ 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/**'
- 'grammar/**'
- '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:
Expand Down Expand Up @@ -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'
'<configuration><packageSources><clear/><add key="nuget.org" value="https://api.nuget.org/v3/index.json"/></packageSources></configuration>' | 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.' }
Loading
Loading