From f0f973cb19593de0561c167ae5ff4a5138ee1a83 Mon Sep 17 00:00:00 2001 From: cangelosilima Date: Sat, 26 Sep 2026 19:41:14 -0300 Subject: [PATCH 1/2] Publish grammar and CLI NuGet packages with CalVer --- .github/workflows/cli-publish-nuget.yml | 100 ++++++++++++++++++ .github/workflows/grammar-publish-nuget.yml | 96 +++++++++++++++++ cli/Directory.Build.props | 3 +- cli/docs/cli.md | 8 +- cli/src/SyncSql.Cli/SyncSql.Cli.csproj | 8 ++ .../SyncSql.Grammar.PlSql.csproj | 11 +- docs/nuget-publishing.md | 72 +++++++++++++ grammar/README.md | 12 +++ 8 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/cli-publish-nuget.yml create mode 100644 .github/workflows/grammar-publish-nuget.yml create mode 100644 docs/nuget-publishing.md diff --git a/.github/workflows/cli-publish-nuget.yml b/.github/workflows/cli-publish-nuget.yml new file mode 100644 index 0000000..c6eeabc --- /dev/null +++ b/.github/workflows/cli-publish-nuget.yml @@ -0,0 +1,100 @@ +name: Publish SyncSql.Cli to NuGet + +on: + push: + tags: ['cli-v*'] + pull_request: + paths: + - 'cli/**' + - 'grammar/**' + - 'global.json' + - '.github/workflows/cli-publish-nuget.yml' + workflow_dispatch: + inputs: + publish: + description: Publish the selected release tag to NuGet (otherwise only validate) + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: nuget-cli-${{ github.ref }} + cancel-in-progress: false + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Resolve and validate version + shell: pwsh + env: + PUBLISH: ${{ github.event_name == 'push' || inputs.publish }} + run: | + $version = [DateTime]::UtcNow.ToString('yyyy.M.d', [Globalization.CultureInfo]::InvariantCulture) + "-ci.$env:GITHUB_RUN_NUMBER.$env:GITHUB_RUN_ATTEMPT" + if ($env:GITHUB_REF -like 'refs/tags/cli-v*') { + $version = $env:GITHUB_REF_NAME.Substring('cli-v'.Length) + if ($version -cnotmatch '^(?[1-9][0-9]{3})\.(?[1-9]|1[0-2])\.(?[1-9]|[12][0-9]|3[01])(\.(?[1-9][0-9]{0,4}))?(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$') { + throw "Invalid release version: $version. Use cli-vYYYY.M.D[.REV] with an optional prerelease suffix." + } + if ([int]$Matches.day -gt [DateTime]::DaysInMonth([int]$Matches.year, [int]$Matches.month)) { + throw "Invalid calendar date in release version: $version." + } + if ($Matches.revision -and [int]$Matches.revision -gt 65534) { + throw 'The same-day revision must be between 1 and 65534 for .NET assembly version compatibility.' + } + } elseif ($env:PUBLISH -eq 'true') { + throw 'Publishing requires selecting an existing cli-v* tag.' + } + "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV + - name: Test + run: dotnet test cli/SyncSql.slnx --configuration Release -p:ContinuousIntegrationBuild=true + - name: Pack + run: >- + dotnet pack cli/src/SyncSql.Cli/SyncSql.Cli.csproj + --configuration Release --output artifacts + -p:Version="$PACKAGE_VERSION" -p:ContinuousIntegrationBuild=true + - name: Smoke test the packaged tool + run: | + dotnet tool install SyncSql.Cli --tool-path "$RUNNER_TEMP/syncsql-tool" --add-source "$PWD/artifacts" --version "$PACKAGE_VERSION" + "$RUNNER_TEMP/syncsql-tool/syncsql" --help + - uses: actions/upload-artifact@v4 + with: + name: SyncSql.Cli-nuget + path: artifacts/SyncSql.Cli.*.nupkg + if-no-files-found: error + + publish: + needs: package + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - uses: actions/download-artifact@v4 + with: + name: SyncSql.Cli-nuget + path: artifacts + - name: Authenticate to NuGet + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} + - name: Publish package + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: >- + dotnet nuget push "artifacts/SyncSql.Cli.*.nupkg" + --source https://api.nuget.org/v3/index.json + --api-key "$NUGET_API_KEY" --skip-duplicate diff --git a/.github/workflows/grammar-publish-nuget.yml b/.github/workflows/grammar-publish-nuget.yml new file mode 100644 index 0000000..fdcad24 --- /dev/null +++ b/.github/workflows/grammar-publish-nuget.yml @@ -0,0 +1,96 @@ +name: Publish SyncSql.Grammar.PlSql to NuGet + +on: + push: + tags: ['grammar-v*'] + pull_request: + paths: + - 'cli/**' + - 'grammar/**' + - 'global.json' + - '.github/workflows/grammar-publish-nuget.yml' + workflow_dispatch: + inputs: + publish: + description: Publish the selected release tag to NuGet (otherwise only validate) + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: nuget-grammar-${{ github.ref }} + cancel-in-progress: false + +jobs: + package: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + - name: Resolve and validate version + shell: pwsh + env: + PUBLISH: ${{ github.event_name == 'push' || inputs.publish }} + run: | + $version = [DateTime]::UtcNow.ToString('yyyy.M.d', [Globalization.CultureInfo]::InvariantCulture) + "-ci.$env:GITHUB_RUN_NUMBER.$env:GITHUB_RUN_ATTEMPT" + if ($env:GITHUB_REF -like 'refs/tags/grammar-v*') { + $version = $env:GITHUB_REF_NAME.Substring('grammar-v'.Length) + if ($version -cnotmatch '^(?[1-9][0-9]{3})\.(?[1-9]|1[0-2])\.(?[1-9]|[12][0-9]|3[01])(\.(?[1-9][0-9]{0,4}))?(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$') { + throw "Invalid release version: $version. Use grammar-vYYYY.M.D[.REV] with an optional prerelease suffix." + } + if ([int]$Matches.day -gt [DateTime]::DaysInMonth([int]$Matches.year, [int]$Matches.month)) { + throw "Invalid calendar date in release version: $version." + } + if ($Matches.revision -and [int]$Matches.revision -gt 65534) { + throw 'The same-day revision must be between 1 and 65534 for .NET assembly version compatibility.' + } + } elseif ($env:PUBLISH -eq 'true') { + throw 'Publishing requires selecting an existing grammar-v* tag.' + } + "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV + - name: Test + run: dotnet test cli/tests/SyncSql.Lineage.Oracle.Tests/SyncSql.Lineage.Oracle.Tests.csproj --configuration Release -p:ContinuousIntegrationBuild=true + - name: Pack + run: >- + dotnet pack cli/src/SyncSql.Grammar.PlSql/SyncSql.Grammar.PlSql.csproj + --configuration Release --output artifacts + -p:Version="$PACKAGE_VERSION" -p:ContinuousIntegrationBuild=true + - uses: actions/upload-artifact@v4 + with: + name: SyncSql.Grammar.PlSql-nuget + path: artifacts/SyncSql.Grammar.PlSql.*.nupkg + if-no-files-found: error + + publish: + needs: package + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + id-token: write + steps: + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - uses: actions/download-artifact@v4 + with: + name: SyncSql.Grammar.PlSql-nuget + path: artifacts + - name: Authenticate to NuGet + uses: NuGet/login@v1 + id: login + with: + user: ${{ secrets.NUGET_USER }} + - name: Publish package + env: + NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} + run: >- + dotnet nuget push "artifacts/SyncSql.Grammar.PlSql.*.nupkg" + --source https://api.nuget.org/v3/index.json + --api-key "$NUGET_API_KEY" --skip-duplicate diff --git a/cli/Directory.Build.props b/cli/Directory.Build.props index cd51e78..f9133e9 100644 --- a/cli/Directory.Build.props +++ b/cli/Directory.Build.props @@ -20,7 +20,8 @@ SyncSQL SyncSQL - 1.0.0 + + $([System.DateTime]::UtcNow.ToString('yyyy.M.d'))-dev https://github.com/cangelosilima/SyncSQL diff --git a/cli/docs/cli.md b/cli/docs/cli.md index 6415d23..c9e07cc 100644 --- a/cli/docs/cli.md +++ b/cli/docs/cli.md @@ -45,14 +45,14 @@ Windows authentication can instead use the identity running the CLI. `syncsql` is published as a [dotnet global tool](https://learn.microsoft.com/dotnet/core/tools/global-tools). ```bash -dotnet tool install --global SyncSql.Cli --add-source +dotnet tool install --global SyncSql.Cli ``` Once installed, the `syncsql` command is on your `PATH` (dotnet prints the exact line to add if it isn't already). Upgrade with: ```bash -dotnet tool update --global SyncSql.Cli --add-source +dotnet tool update --global SyncSql.Cli ``` Requires the [.NET 10 runtime](https://dotnet.microsoft.com/download) (or @@ -62,6 +62,10 @@ driver. ### Building and installing from source +Maintainers publish to nuget.org with independent `cli-v*` release tags. See +[NuGet publishing](../../docs/nuget-publishing.md) for authentication setup and +the separate grammar release workflow. + ```bash cd cli dotnet pack src/SyncSql.Cli -c Release diff --git a/cli/src/SyncSql.Cli/SyncSql.Cli.csproj b/cli/src/SyncSql.Cli/SyncSql.Cli.csproj index 379ecfc..66b6614 100644 --- a/cli/src/SyncSql.Cli/SyncSql.Cli.csproj +++ b/cli/src/SyncSql.Cli/SyncSql.Cli.csproj @@ -32,8 +32,16 @@ true syncsql SyncSql.Cli + README.md + git Extraction, lineage, and catalog building for a fleet of MSSQL/Oracle servers - the syncsql CLI. $(MSBuildThisFileDirectory)../../nupkg + + + + + + diff --git a/cli/src/SyncSql.Grammar.PlSql/SyncSql.Grammar.PlSql.csproj b/cli/src/SyncSql.Grammar.PlSql/SyncSql.Grammar.PlSql.csproj index 49c201e..705b8c8 100644 --- a/cli/src/SyncSql.Grammar.PlSql/SyncSql.Grammar.PlSql.csproj +++ b/cli/src/SyncSql.Grammar.PlSql/SyncSql.Grammar.PlSql.csproj @@ -10,7 +10,13 @@ false false false - false + true + SyncSql.Grammar.PlSql + Oracle SQL and PL/SQL lexer, parser, and visitor generated with ANTLR 4.13.1. Targets .NET Standard 2.0. + Apache-2.0 + README.md + git + oracle;sql;plsql;antlr;parser;grammar $(NoWarn);CS3021 @@ -35,6 +41,9 @@ + + + diff --git a/docs/nuget-publishing.md b/docs/nuget-publishing.md new file mode 100644 index 0000000..f0164fc --- /dev/null +++ b/docs/nuget-publishing.md @@ -0,0 +1,72 @@ +# Publishing NuGet packages + +The grammar and CLI have independent releases on nuget.org: + +| Package | Workflow | Release tag | +| --- | --- | --- | +| `SyncSql.Grammar.PlSql` | `grammar-publish-nuget.yml` | `grammar-v2026.9.26` | +| `SyncSql.Cli` (.NET tool, command `syncsql`) | `cli-publish-nuget.yml` | `cli-v2026.9.26` | + +The version comes from the tag, overriding the development version in +`cli/Directory.Build.props`. Releases use **CalVer `YYYY.M.D[.REV]`**: the release +date with no leading zeros on the month or day. For another release on the same +date, append an incrementing revision, starting at `.1` (for example, +`cli-v2026.9.26.1`). Revisions range from 1 to 65534 for .NET assembly compatibility. +Each package has its own revision sequence. Invalid calendar dates are rejected. +Prereleases such as `grammar-v2026.9.26-rc.1` are supported; build metadata is not. +Local builds default to the current UTC date followed by `-dev`. +Publishing the CLI does not require publishing +the grammar first: the tool bundles the grammar assembly through its existing +project reference. The standalone grammar targets .NET Standard 2.0 and declares +its ANTLR runtime dependency. + +## One-time setup + +1. Merge the workflows and package changes into the repository. +2. Add the Actions repository secret `NUGET_USER`, containing the NuGet profile + username (not an email address) used to publish. +3. In nuget.org, configure two + [trusted publishing policies](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) + for repository owner `cangelosilima`, repository `SyncSQL`. Set the workflow + filenames to `grammar-publish-nuget.yml` and `cli-publish-nuget.yml`, respectively. + Leave environment empty; these workflows do not use GitHub environments. + Scope each policy to its corresponding package ID and permit publishing new + packages as well as new versions if the package has not yet been published. + The publishing account must own existing package IDs. + +No long-lived NuGet API key is stored. The publish job obtains a temporary key +with GitHub OIDC after the package job succeeds, then uploads the tested artifact. + +## Release + +From the reviewed commit to release, push only the desired tag: + +```bash +git tag grammar-v2026.9.26 +git push origin grammar-v2026.9.26 + +# Independently, when ready to release the CLI: +git tag cli-v2026.9.26 +git push origin cli-v2026.9.26 +``` + +Each workflow runs tests, builds its package, and uploads a downloadable workflow +artifact before publishing. The CLI also installs the packed tool locally and +runs `syncsql --help`. Pull requests validate both packages without publishing. + +For a manual dry run, run either workflow with **publish** unchecked. Branch runs +use the current UTC date with `-ci.RUN_NUMBER.RUN_ATTEMPT`; selecting a matching +release tag validates that exact version. +For a manual publication, select an existing matching release tag and check +**publish**. Branches and malformed versions cannot publish. Reruns skip versions +already on NuGet; use a new tag/version to release changed content. + +## Consume + +```bash +dotnet add package SyncSql.Grammar.PlSql +dotnet tool install --global SyncSql.Cli +``` + +The CLI requires the .NET 10 runtime. The grammar can be used independently by +.NET Standard 2.0 compatible applications. diff --git a/grammar/README.md b/grammar/README.md index 6a54c6e..a9cc9fc 100644 --- a/grammar/README.md +++ b/grammar/README.md @@ -2,6 +2,18 @@ The complete Oracle SQL and PL/SQL parser used by `SyncSql.Lineage.Oracle`. +## NuGet package + +Install the standalone .NET Standard 2.0 parser with: + +```bash +dotnet add package SyncSql.Grammar.PlSql +``` + +The package includes the ANTLR runtime dependency and the upstream license and +notice. It is released independently of the CLI using `grammar-v*` tags; see +[NuGet publishing](../docs/nuget-publishing.md) for setup and release instructions. + ## Java-free build design The human-readable grammar remains the Apache-2.0 `sql/plsql` grammar from From 84899448ea559f6ca58fa85ad0ec13e712cd3bb2 Mon Sep 17 00:00:00 2001 From: cangelosilima Date: Sat, 26 Sep 2026 21:23:29 -0300 Subject: [PATCH 2/2] Publish from main and create package releases automatically --- .github/scripts/nuget-release.cjs | 70 +++++++++++ .github/scripts/nuget-release.test.cjs | 122 ++++++++++++++++++++ .github/workflows/cli-publish-nuget.yml | 76 +++++++----- .github/workflows/grammar-publish-nuget.yml | 86 +++++++++----- cli/docs/cli.md | 4 +- docs/nuget-publishing.md | 107 +++++++++-------- grammar/README.md | 3 +- 7 files changed, 364 insertions(+), 104 deletions(-) create mode 100644 .github/scripts/nuget-release.cjs create mode 100644 .github/scripts/nuget-release.test.cjs diff --git a/.github/scripts/nuget-release.cjs b/.github/scripts/nuget-release.cjs new file mode 100644 index 0000000..fd829c6 --- /dev/null +++ b/.github/scripts/nuget-release.cjs @@ -0,0 +1,70 @@ +const fs = require('node:fs'); +const path = require('node:path'); + +function requireMainPush(context) { + if (context.eventName !== 'push' || context.ref !== 'refs/heads/main') { + throw new Error('Publishing is only allowed for a push to main.'); + } +} + +async function findTag({ github, context, tag }) { + try { + const { data } = await github.rest.git.getRef({ ...context.repo, ref: `tags/${tag}` }); + if (data.object.type !== 'commit' || data.object.sha !== context.sha) { + throw new Error(`Tag ${tag} does not point to the tested main commit.`); + } + return data; + } catch (error) { + if (error.status === 404) return null; + throw error; + } +} + +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 + }); + } +} + +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)); + 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 + })); + } + 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 } + }); + } + if (release.draft) { + await github.rest.repos.updateRelease({ + ...context.repo, release_id: release.id, draft: false, make_latest: 'false' + }); + } +} + +module.exports = { requireMainPush, reserveTag, publishRelease }; diff --git a/.github/scripts/nuget-release.test.cjs b/.github/scripts/nuget-release.test.cjs new file mode 100644 index 0000000..14f17df --- /dev/null +++ b/.github/scripts/nuget-release.test.cjs @@ -0,0 +1,122 @@ +const { test, after } = 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 { reserveTag, publishRelease } = require('./nuget-release.cjs'); + +const artifacts = fs.mkdtempSync(path.join(os.tmpdir(), 'syncsql-nuget-release-')); +after(() => fs.rmSync(artifacts, { recursive: true })); +fs.writeFileSync(path.join(artifacts, 'SyncSql.Cli.2026.9.26.42.nupkg'), 'package bytes'); + +function fixture() { + const calls = []; + const state = { tag: null, release: null, assets: [] }; + const missing = () => { throw Object.assign(new Error('Not found'), { status: 404 }); }; + const github = { + rest: { + git: { + getRef: async () => state.tag ? { data: state.tag } : missing(), + createRef: async args => { + calls.push(['tag', args]); + state.tag = { object: { type: 'commit', sha: args.sha } }; + } + }, + repos: { + getReleaseByTag: async () => state.release ? { data: state.release } : missing(), + createRelease: async args => { + calls.push(['draft', args]); + state.release = { id: 7, draft: true }; + return { data: state.release }; + }, + listReleaseAssets: async () => state.assets, + deleteReleaseAsset: async args => { calls.push(['delete', args]); state.assets = []; }, + uploadReleaseAsset: async args => { + calls.push(['upload', args]); + state.assets.push({ id: 8, name: args.name, state: 'uploaded' }); + }, + updateRelease: async args => { + calls.push(['publish', args]); + state.release.draft = args.draft; + } + } + }, + paginate: async method => method() + }; + return { + calls, state, github, artifacts, tag: 'cli-v2026.9.26.42', + packageId: 'SyncSql.Cli', version: '2026.9.26.42', + context: { eventName: 'push', ref: 'refs/heads/main', sha: 'tested-sha', repo: { owner: 'owner', repo: 'repo' } } + }; +} + +for (const [eventName, ref] of [ + ['pull_request', 'refs/pull/69/merge'], ['workflow_dispatch', 'refs/heads/main'], + ['push', 'refs/heads/feature'], ['push', 'refs/tags/cli-v2026.9.26.42'] +]) { + test(`rejects ${eventName} on ${ref} before writing anything`, async () => { + const f = fixture(); + Object.assign(f.context, { eventName, ref }); + await assert.rejects(reserveTag(f), /only allowed/); + await assert.rejects(publishRelease(f), /only allowed/); + assert.deepEqual(f.calls, []); + }); +} + +test('tags the tested commit, uploads to a draft, then publishes; reruns are idempotent', async () => { + const f = fixture(); + await reserveTag(f); + await publishRelease(f); + assert.deepEqual(f.calls.map(([name]) => name), ['tag', 'draft', 'upload', 'publish']); + assert.equal(f.calls[0][1].sha, 'tested-sha'); + assert.equal(f.calls[1][1].target_commitish, 'tested-sha'); + assert.equal(f.calls[1][1].generate_release_notes, true); + assert.equal(f.calls[2][1].data.toString(), 'package bytes'); + assert.equal(f.calls[3][1].draft, false); + await reserveTag(f); + await publishRelease(f); + assert.equal(f.calls.length, 4); +}); + +test('rejects a tag pointing elsewhere rather than moving it', async () => { + const f = fixture(); + f.state.tag = { object: { type: 'commit', sha: 'different-sha' } }; + await assert.rejects(reserveTag(f), /tested main commit/); + await assert.rejects(publishRelease(f), /tested main commit/); + assert.deepEqual(f.calls, []); +}); + +test('requires a reserved tag before creating a release', async () => { + const f = fixture(); + await assert.rejects(publishRelease(f), /must be reserved/); + assert.deepEqual(f.calls, []); +}); + +test('propagates API authorization errors instead of treating them as missing tags', async () => { + const f = fixture(); + f.github.rest.git.getRef = async () => { throw Object.assign(new Error('Forbidden'), { status: 403 }); }; + await assert.rejects(reserveTag(f), /Forbidden/); + assert.deepEqual(f.calls, []); +}); + +test('an upload failure leaves a draft that can be completed on retry', async () => { + const f = fixture(); + await reserveTag(f); + const upload = f.github.rest.repos.uploadReleaseAsset; + f.github.rest.repos.uploadReleaseAsset = async () => { throw new Error('Upload failed'); }; + await assert.rejects(publishRelease(f), /Upload failed/); + assert.equal(f.state.release.draft, true); + f.github.rest.repos.uploadReleaseAsset = upload; + await publishRelease(f); + assert.equal(f.state.release.draft, false); + assert.equal(f.calls.filter(([name]) => name === 'draft').length, 1); +}); + +test('replaces an incomplete asset before publishing a recovered draft', async () => { + const f = fixture(); + await reserveTag(f); + f.state.release = { id: 7, draft: true }; + f.state.assets = [{ id: 8, name: 'SyncSql.Cli.2026.9.26.42.nupkg', state: 'starter' }]; + await publishRelease(f); + assert.deepEqual(f.calls.map(([name]) => name), ['tag', 'delete', 'upload', 'publish']); +}); diff --git a/.github/workflows/cli-publish-nuget.yml b/.github/workflows/cli-publish-nuget.yml index c6eeabc..b8820e3 100644 --- a/.github/workflows/cli-publish-nuget.yml +++ b/.github/workflows/cli-publish-nuget.yml @@ -2,19 +2,21 @@ name: Publish SyncSql.Cli to NuGet on: push: - tags: ['cli-v*'] + branches: [main] + paths: + - 'cli/**' + - 'grammar/**' + - 'global.json' + - '.github/workflows/cli-publish-nuget.yml' + - '.github/scripts/nuget-release*.cjs' pull_request: paths: - 'cli/**' - 'grammar/**' - 'global.json' - '.github/workflows/cli-publish-nuget.yml' + - '.github/scripts/nuget-release*.cjs' workflow_dispatch: - inputs: - publish: - description: Publish the selected release tag to NuGet (otherwise only validate) - type: boolean - default: false permissions: contents: read @@ -27,32 +29,32 @@ jobs: package: runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - - name: Resolve and validate version + - name: Test release automation + run: node --test .github/scripts/nuget-release.test.cjs + - name: Resolve CalVer + id: version shell: pwsh - env: - PUBLISH: ${{ github.event_name == 'push' || inputs.publish }} run: | - $version = [DateTime]::UtcNow.ToString('yyyy.M.d', [Globalization.CultureInfo]::InvariantCulture) + "-ci.$env:GITHUB_RUN_NUMBER.$env:GITHUB_RUN_ATTEMPT" - if ($env:GITHUB_REF -like 'refs/tags/cli-v*') { - $version = $env:GITHUB_REF_NAME.Substring('cli-v'.Length) - if ($version -cnotmatch '^(?[1-9][0-9]{3})\.(?[1-9]|1[0-2])\.(?[1-9]|[12][0-9]|3[01])(\.(?[1-9][0-9]{0,4}))?(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$') { - throw "Invalid release version: $version. Use cli-vYYYY.M.D[.REV] with an optional prerelease suffix." - } - if ([int]$Matches.day -gt [DateTime]::DaysInMonth([int]$Matches.year, [int]$Matches.month)) { - throw "Invalid calendar date in release version: $version." - } - if ($Matches.revision -and [int]$Matches.revision -gt 65534) { - throw 'The same-day revision must be between 1 and 65534 for .NET assembly version compatibility.' - } - } elseif ($env:PUBLISH -eq 'true') { - throw 'Publishing requires selecting an existing cli-v* tag.' + $timestamp = git show -s --format=%cI $env:GITHUB_SHA + if ($LASTEXITCODE -ne 0) { throw 'Cannot read the build commit date.' } + $date = [DateTimeOffset]::Parse($timestamp, [Globalization.CultureInfo]::InvariantCulture).UtcDateTime + $revision = [int]$env:GITHUB_RUN_NUMBER + if ($revision -lt 1 -or $revision -gt 65534) { throw 'Run number must be between 1 and 65534 for .NET assembly compatibility.' } + $version = $date.ToString('yyyy.M.d', [Globalization.CultureInfo]::InvariantCulture) + ".$revision" + if ($env:GITHUB_EVENT_NAME -ne 'push' -or $env:GITHUB_REF -ne 'refs/heads/main') { + $version += "-ci.$env:GITHUB_RUN_ATTEMPT" } "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV + "version=$version" >> $env:GITHUB_OUTPUT + "tag=cli-v$version" >> $env:GITHUB_OUTPUT - name: Test run: dotnet test cli/SyncSql.slnx --configuration Release -p:ContinuousIntegrationBuild=true - name: Pack @@ -72,20 +74,31 @@ jobs: publish: needs: package - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: read + contents: write id-token: write + env: + RELEASE_TAG: ${{ needs.package.outputs.tag }} + PACKAGE_VERSION: ${{ needs.package.outputs.version }} + PACKAGE_ID: SyncSql.Cli steps: + - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - dotnet-version: '10.0.x' + global-json-file: global.json - uses: actions/download-artifact@v4 with: name: SyncSql.Cli-nuget path: artifacts + - name: Reserve release tag at the tested main commit + uses: actions/github-script@v7 + with: + script: | + const { reserveTag } = require('./.github/scripts/nuget-release.cjs'); + await reserveTag({ github, context, tag: process.env.RELEASE_TAG }); - name: Authenticate to NuGet uses: NuGet/login@v1 id: login @@ -95,6 +108,15 @@ jobs: env: NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} run: >- - dotnet nuget push "artifacts/SyncSql.Cli.*.nupkg" + dotnet nuget push "artifacts/SyncSql.Cli.$PACKAGE_VERSION.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + - name: Create GitHub release with the published package + uses: actions/github-script@v7 + with: + script: | + const { publishRelease } = require('./.github/scripts/nuget-release.cjs'); + await publishRelease({ + github, context, tag: process.env.RELEASE_TAG, + packageId: process.env.PACKAGE_ID, version: process.env.PACKAGE_VERSION + }); diff --git a/.github/workflows/grammar-publish-nuget.yml b/.github/workflows/grammar-publish-nuget.yml index fdcad24..dbd13d4 100644 --- a/.github/workflows/grammar-publish-nuget.yml +++ b/.github/workflows/grammar-publish-nuget.yml @@ -2,19 +2,29 @@ name: Publish SyncSql.Grammar.PlSql to NuGet on: push: - tags: ['grammar-v*'] + branches: [main] + paths: + - 'grammar/**' + - 'cli/src/SyncSql.Grammar.PlSql/**' + - 'cli/src/SyncSql.Lineage.Oracle/**' + - 'cli/src/SyncSql.Core/**' + - 'cli/tests/SyncSql.Lineage.Oracle.Tests/**' + - 'cli/Directory.Build.props' + - 'global.json' + - '.github/workflows/grammar-publish-nuget.yml' + - '.github/scripts/nuget-release*.cjs' pull_request: paths: - - 'cli/**' - 'grammar/**' + - 'cli/src/SyncSql.Grammar.PlSql/**' + - 'cli/src/SyncSql.Lineage.Oracle/**' + - 'cli/src/SyncSql.Core/**' + - 'cli/tests/SyncSql.Lineage.Oracle.Tests/**' + - 'cli/Directory.Build.props' - 'global.json' - '.github/workflows/grammar-publish-nuget.yml' + - '.github/scripts/nuget-release*.cjs' workflow_dispatch: - inputs: - publish: - description: Publish the selected release tag to NuGet (otherwise only validate) - type: boolean - default: false permissions: contents: read @@ -27,32 +37,32 @@ jobs: package: runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} steps: - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: global-json-file: global.json - - name: Resolve and validate version + - name: Test release automation + run: node --test .github/scripts/nuget-release.test.cjs + - name: Resolve CalVer + id: version shell: pwsh - env: - PUBLISH: ${{ github.event_name == 'push' || inputs.publish }} run: | - $version = [DateTime]::UtcNow.ToString('yyyy.M.d', [Globalization.CultureInfo]::InvariantCulture) + "-ci.$env:GITHUB_RUN_NUMBER.$env:GITHUB_RUN_ATTEMPT" - if ($env:GITHUB_REF -like 'refs/tags/grammar-v*') { - $version = $env:GITHUB_REF_NAME.Substring('grammar-v'.Length) - if ($version -cnotmatch '^(?[1-9][0-9]{3})\.(?[1-9]|1[0-2])\.(?[1-9]|[12][0-9]|3[01])(\.(?[1-9][0-9]{0,4}))?(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?$') { - throw "Invalid release version: $version. Use grammar-vYYYY.M.D[.REV] with an optional prerelease suffix." - } - if ([int]$Matches.day -gt [DateTime]::DaysInMonth([int]$Matches.year, [int]$Matches.month)) { - throw "Invalid calendar date in release version: $version." - } - if ($Matches.revision -and [int]$Matches.revision -gt 65534) { - throw 'The same-day revision must be between 1 and 65534 for .NET assembly version compatibility.' - } - } elseif ($env:PUBLISH -eq 'true') { - throw 'Publishing requires selecting an existing grammar-v* tag.' + $timestamp = git show -s --format=%cI $env:GITHUB_SHA + if ($LASTEXITCODE -ne 0) { throw 'Cannot read the build commit date.' } + $date = [DateTimeOffset]::Parse($timestamp, [Globalization.CultureInfo]::InvariantCulture).UtcDateTime + $revision = [int]$env:GITHUB_RUN_NUMBER + if ($revision -lt 1 -or $revision -gt 65534) { throw 'Run number must be between 1 and 65534 for .NET assembly compatibility.' } + $version = $date.ToString('yyyy.M.d', [Globalization.CultureInfo]::InvariantCulture) + ".$revision" + if ($env:GITHUB_EVENT_NAME -ne 'push' -or $env:GITHUB_REF -ne 'refs/heads/main') { + $version += "-ci.$env:GITHUB_RUN_ATTEMPT" } "PACKAGE_VERSION=$version" >> $env:GITHUB_ENV + "version=$version" >> $env:GITHUB_OUTPUT + "tag=grammar-v$version" >> $env:GITHUB_OUTPUT - name: Test run: dotnet test cli/tests/SyncSql.Lineage.Oracle.Tests/SyncSql.Lineage.Oracle.Tests.csproj --configuration Release -p:ContinuousIntegrationBuild=true - name: Pack @@ -68,20 +78,31 @@ jobs: publish: needs: package - if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: read + contents: write id-token: write + env: + RELEASE_TAG: ${{ needs.package.outputs.tag }} + PACKAGE_VERSION: ${{ needs.package.outputs.version }} + PACKAGE_ID: SyncSql.Grammar.PlSql steps: + - uses: actions/checkout@v4 - uses: actions/setup-dotnet@v4 with: - dotnet-version: '10.0.x' + global-json-file: global.json - uses: actions/download-artifact@v4 with: name: SyncSql.Grammar.PlSql-nuget path: artifacts + - name: Reserve release tag at the tested main commit + uses: actions/github-script@v7 + with: + script: | + const { reserveTag } = require('./.github/scripts/nuget-release.cjs'); + await reserveTag({ github, context, tag: process.env.RELEASE_TAG }); - name: Authenticate to NuGet uses: NuGet/login@v1 id: login @@ -91,6 +112,15 @@ jobs: env: NUGET_API_KEY: ${{ steps.login.outputs.NUGET_API_KEY }} run: >- - dotnet nuget push "artifacts/SyncSql.Grammar.PlSql.*.nupkg" + dotnet nuget push "artifacts/SyncSql.Grammar.PlSql.$PACKAGE_VERSION.nupkg" --source https://api.nuget.org/v3/index.json --api-key "$NUGET_API_KEY" --skip-duplicate + - name: Create GitHub release with the published package + uses: actions/github-script@v7 + with: + script: | + const { publishRelease } = require('./.github/scripts/nuget-release.cjs'); + await publishRelease({ + github, context, tag: process.env.RELEASE_TAG, + packageId: process.env.PACKAGE_ID, version: process.env.PACKAGE_VERSION + }); diff --git a/cli/docs/cli.md b/cli/docs/cli.md index c9e07cc..af3bf25 100644 --- a/cli/docs/cli.md +++ b/cli/docs/cli.md @@ -62,14 +62,14 @@ driver. ### Building and installing from source -Maintainers publish to nuget.org with independent `cli-v*` release tags. See +Merges into `main` publish to nuget.org and create independent `cli-v*` GitHub releases. See [NuGet publishing](../../docs/nuget-publishing.md) for authentication setup and the separate grammar release workflow. ```bash cd cli dotnet pack src/SyncSql.Cli -c Release -dotnet tool install --global --add-source ./nupkg SyncSql.Cli +dotnet tool install --global --add-source ./nupkg --prerelease SyncSql.Cli ``` #### Build prerequisites diff --git a/docs/nuget-publishing.md b/docs/nuget-publishing.md index f0164fc..53b87a3 100644 --- a/docs/nuget-publishing.md +++ b/docs/nuget-publishing.md @@ -1,65 +1,80 @@ # Publishing NuGet packages -The grammar and CLI have independent releases on nuget.org: +The grammar and CLI publish independently when relevant changes are merged into +`main`. Each workflow tests and packages its component, publishes it to nuget.org, +and creates a GitHub release with generated notes and the `.nupkg` attached. -| Package | Workflow | Release tag | +| Package | Workflow | Example generated release tag | | --- | --- | --- | -| `SyncSql.Grammar.PlSql` | `grammar-publish-nuget.yml` | `grammar-v2026.9.26` | -| `SyncSql.Cli` (.NET tool, command `syncsql`) | `cli-publish-nuget.yml` | `cli-v2026.9.26` | - -The version comes from the tag, overriding the development version in -`cli/Directory.Build.props`. Releases use **CalVer `YYYY.M.D[.REV]`**: the release -date with no leading zeros on the month or day. For another release on the same -date, append an incrementing revision, starting at `.1` (for example, -`cli-v2026.9.26.1`). Revisions range from 1 to 65534 for .NET assembly compatibility. -Each package has its own revision sequence. Invalid calendar dates are rejected. -Prereleases such as `grammar-v2026.9.26-rc.1` are supported; build metadata is not. -Local builds default to the current UTC date followed by `-dev`. -Publishing the CLI does not require publishing -the grammar first: the tool bundles the grammar assembly through its existing -project reference. The standalone grammar targets .NET Standard 2.0 and declares -its ANTLR runtime dependency. +| `SyncSql.Grammar.PlSql` | `grammar-publish-nuget.yml` | `grammar-v2026.9.26.42` | +| `SyncSql.Cli` (.NET tool, command `syncsql`) | `cli-publish-nuget.yml` | `cli-v2026.9.26.57` | + +## CalVer + +Versions use **`YYYY.M.D.BUILD`**, without leading zeros. The date is the tested +main commit's UTC committer date, and BUILD is the workflow's run number. Each +workflow has its own sequence; validation runs can leave gaps. BUILD must be +between 1 and 65534 for .NET assembly compatibility. + +The date and run number stay the same when a failed run is rerun, even on another +day. The generated version overrides `cli/Directory.Build.props`; local builds +default to the current UTC date followed by `-dev`. + +Publishing the CLI does not require publishing the grammar first: the tool +bundles the grammar assembly through its project reference. The standalone +grammar targets .NET Standard 2.0 and declares its ANTLR runtime dependency. ## One-time setup -1. Merge the workflows and package changes into the repository. -2. Add the Actions repository secret `NUGET_USER`, containing the NuGet profile - username (not an email address) used to publish. -3. In nuget.org, configure two +Before merging the workflows: + +1. Set the Actions repository secret `NUGET_USER` to the NuGet profile username + (currently `this.programmer`, not an email address). +2. In nuget.org, configure two [trusted publishing policies](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) - for repository owner `cangelosilima`, repository `SyncSQL`. Set the workflow - filenames to `grammar-publish-nuget.yml` and `cli-publish-nuget.yml`, respectively. - Leave environment empty; these workflows do not use GitHub environments. - Scope each policy to its corresponding package ID and permit publishing new - packages as well as new versions if the package has not yet been published. - The publishing account must own existing package IDs. + for owner `cangelosilima`, repository `SyncSQL`. Use workflow filenames + `grammar-publish-nuget.yml` and `cli-publish-nuget.yml`. + Leave environment empty; the workflows do not use GitHub environments. + Scope each policy to its package ID and permit new packages as well as new + versions for the first publication. The account must own existing package IDs. +3. Allow these workflows to create release tags and GitHub releases. The publish + job requests `contents: write` and `id-token: write`. No long-lived NuGet API key is stored. The publish job obtains a temporary key -with GitHub OIDC after the package job succeeds, then uploads the tested artifact. +with GitHub OIDC after the package job succeeds. -## Release +## Release and recovery -From the reviewed commit to release, push only the desired tag: +Merge the reviewed changes into `main`; do not create tags manually. Only +`push` events on `refs/heads/main` may publish. Tag pushes, pull requests, +other branches, and manual workflow runs cannot publish or create releases. +Protect `main` with the repository's required reviews and checks to ensure +its push events come from approved merges. -```bash -git tag grammar-v2026.9.26 -git push origin grammar-v2026.9.26 +The CLI workflow watches CLI, grammar, SDK, and publishing-automation changes. +The grammar workflow watches grammar sources, its project, relevant Oracle +lineage/Core sources and tests, shared build settings, SDK, and its publishing +automation. A CLI-only change does not publish a new grammar package. -# Independently, when ready to release the CLI: -git tag cli-v2026.9.26 -git push origin cli-v2026.9.26 -``` +For each selected package, the workflow: + +1. Runs tests, builds the package, and uploads a workflow artifact. +2. For the CLI, installs the packed tool and runs `syncsql --help`. +3. Reserves the generated tag at the exact tested main commit. +4. Publishes that package to NuGet. +5. Creates a draft GitHub release with generated notes, attaches the package, + then publishes the release. -Each workflow runs tests, builds its package, and uploads a downloadable workflow -artifact before publishing. The CLI also installs the packed tool locally and -runs `syncsql --help`. Pull requests validate both packages without publishing. +Rerun a failed workflow to recover. It reuses its version and tag, skips an +already-published NuGet version, and finishes an incomplete GitHub release. +An existing tag pointing to another commit is rejected and never moved. +A failure before NuGet publication can leave a reserved tag; a failure during +asset upload can leave a draft release. Neither triggers another publishing run. -For a manual dry run, run either workflow with **publish** unchecked. Branch runs -use the current UTC date with `-ci.RUN_NUMBER.RUN_ATTEMPT`; selecting a matching -release tag validates that exact version. -For a manual publication, select an existing matching release tag and check -**publish**. Branches and malformed versions cannot publish. Reruns skip versions -already on NuGet; use a new tag/version to release changed content. +Pull requests and manual runs only validate, with versions suffixed +`-ci.RUN_ATTEMPT`. Use **Run workflow** for a dry run; there is no manual publish +option. GitHub releases for the two packages do not replace each other as the +repository's latest release. ## Consume diff --git a/grammar/README.md b/grammar/README.md index a9cc9fc..e388e58 100644 --- a/grammar/README.md +++ b/grammar/README.md @@ -11,7 +11,8 @@ dotnet add package SyncSql.Grammar.PlSql ``` The package includes the ANTLR runtime dependency and the upstream license and -notice. It is released independently of the CLI using `grammar-v*` tags; see +notice. Merges into `main` release it independently of the CLI, automatically +creating `grammar-v*` tags and GitHub releases; see [NuGet publishing](../docs/nuget-publishing.md) for setup and release instructions. ## Java-free build design