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
new file mode 100644
index 0000000..b8820e3
--- /dev/null
+++ b/.github/workflows/cli-publish-nuget.yml
@@ -0,0 +1,122 @@
+name: Publish SyncSql.Cli to NuGet
+
+on:
+ push:
+ 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:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nuget-cli-${{ github.ref }}
+ cancel-in-progress: false
+
+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: Test release automation
+ run: node --test .github/scripts/nuget-release.test.cjs
+ - name: Resolve CalVer
+ id: version
+ shell: pwsh
+ run: |
+ $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
+ 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.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ 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:
+ 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
+ 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.$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
new file mode 100644
index 0000000..dbd13d4
--- /dev/null
+++ b/.github/workflows/grammar-publish-nuget.yml
@@ -0,0 +1,126 @@
+name: Publish SyncSql.Grammar.PlSql to NuGet
+
+on:
+ push:
+ 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:
+ - '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:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: nuget-grammar-${{ github.ref }}
+ cancel-in-progress: false
+
+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: Test release automation
+ run: node --test .github/scripts/nuget-release.test.cjs
+ - name: Resolve CalVer
+ id: version
+ shell: pwsh
+ run: |
+ $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
+ 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.ref == 'refs/heads/main'
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ permissions:
+ 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:
+ 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
+ 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.$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/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..af3bf25 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,10 +62,14 @@ driver.
### Building and installing from source
+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/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..53b87a3
--- /dev/null
+++ b/docs/nuget-publishing.md
@@ -0,0 +1,87 @@
+# Publishing NuGet packages
+
+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 | Example generated release tag |
+| --- | --- | --- |
+| `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
+
+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 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.
+
+## Release and recovery
+
+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.
+
+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.
+
+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.
+
+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.
+
+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
+
+```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..e388e58 100644
--- a/grammar/README.md
+++ b/grammar/README.md
@@ -2,6 +2,19 @@
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. 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
The human-readable grammar remains the Apache-2.0 `sql/plsql` grammar from