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
70 changes: 70 additions & 0 deletions .github/scripts/nuget-release.cjs
Original file line number Diff line number Diff line change
@@ -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 };
122 changes: 122 additions & 0 deletions .github/scripts/nuget-release.test.cjs
Original file line number Diff line number Diff line change
@@ -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']);
});
122 changes: 122 additions & 0 deletions .github/workflows/cli-publish-nuget.yml
Original file line number Diff line number Diff line change
@@ -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
});
Loading
Loading