Skip to content
Open
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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,49 @@ The definition of this GitHub Action is in [action.yml](https://github.com/Azure

# optional, set this to skip checking if the runner has access to the server. Default is false.
skip-firewall-check:

# optional, write a deployment summary to the job summary. Default is true.
summary:

# optional, post the deployment summary as a sticky pull request comment.
# one of 'off', 'auto', or 'always'. Default is 'auto'.
comment-pr:

# optional, token used to post the pull request comment. Defaults to the workflow token.
github-token:
```

## 📝 Deployment summary

When deploying a `.dacpac` or `.sqlproj` with `action: publish`, sql-action captures the SqlPackage deployment report and script and publishes a concise summary of what changed — the objects created, altered, or dropped, any possible data-loss warnings, and the full deployment T-SQL in a collapsible block. The summary is written to the [GitHub Actions job summary](https://github.blog/2022-05-09-supercharging-github-actions-with-job-summaries/) and, on pull requests, to a single sticky comment that is updated on each run.

```yaml
- uses: azure/sql-action@v2.3
with:
connection-string: ${{ secrets.AZURE_SQL_CONNECTION_STRING }}
path: './Database.sqlproj'
action: 'publish'
comment-pr: 'auto' # off | auto | always
```

To let the action post the pull request comment, grant the workflow `pull-requests: write`:

```yaml
permissions:
pull-requests: write
```

The reporting is best-effort and never fails a deployment: if the token or permission is missing, the action falls back to the job summary and logs a warning. Set `summary: false` and `comment-pr: off` to disable it entirely.

The action also exposes these outputs:

| Output | Description |
|--------|-------------|
| `changes-detected` | `'true'` if the deployment changed any database objects, otherwise `'false'`. |
| `objects-changed` | The number of database objects created, altered, or dropped. |
| `deployment-report-path` | Path to the captured SqlPackage deployment report (XML). |
| `deployment-script-path` | Path to the captured SqlPackage deployment script (T-SQL). |

## 🎨 Samples

### Build and deploy a SQL project
Expand Down
20 changes: 20 additions & 0 deletions __testdata__/deployReport.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<DeploymentReport xmlns="http://schemas.microsoft.com/sqlserver/dac/DeployReport/2012/02">
<Alerts>
<Alert Name="DataIssue">
<Issue Value="[dbo].[OldAudit] will be dropped, which may result in data loss." />
</Alert>
</Alerts>
<Operations>
<Operation Name="Create">
<Item Value="[dbo].[Reactions]" Type="SqlTable" />
<Item Value="[dbo].[GetUserMentions]" Type="SqlProcedure" />
</Operation>
<Operation Name="Alter">
<Item Value="[dbo].[Messages]" Type="SqlTable" />
</Operation>
<Operation Name="Drop">
<Item Value="[dbo].[OldAudit]" Type="SqlTable" />
</Operation>
</Operations>
</DeploymentReport>
63 changes: 63 additions & 0 deletions __tests__/AzureSqlAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,69 @@ describe('AzureSqlAction tests', () => {
});
});

describe('deployment report capture', () => {
function getPublishInputs(captureDeploymentReport: boolean, additionalArguments?: string): IDacpacActionInputs {
return {
actionType: ActionType.DacpacAction,
connectionConfig: new SqlConnectionConfig('Server=testServer.database.windows.net;Initial Catalog=testDB;User Id=testUser;Password=placeholder'),
filePath: './TestPackage.dacpac',
sqlpackageAction: SqlPackageAction.Publish,
additionalArguments,
captureDeploymentReport
} as IDacpacActionInputs;
}

it('injects report and script capture arguments for a Publish when enabled', async () => {
const action = new AzureSqlAction(getPublishInputs(true));
jest.spyOn(AzureSqlActionHelper, 'getSqlPackagePath').mockResolvedValue('SqlPackage.exe');
const execSpy = jest.spyOn(exec, 'exec').mockResolvedValue(0);

const result = await action.execute();

expect(execSpy).toHaveBeenCalledWith(expect.stringContaining('/DeployReportPath:'));
expect(execSpy).toHaveBeenCalledWith(expect.stringContaining('/DeployScriptPath:'));
expect(result.reportPath).toBeDefined();
expect(result.scriptPath).toBeDefined();
});

it('does not inject capture arguments when disabled', async () => {
const action = new AzureSqlAction(getPublishInputs(false));
jest.spyOn(AzureSqlActionHelper, 'getSqlPackagePath').mockResolvedValue('SqlPackage.exe');
const execSpy = jest.spyOn(exec, 'exec').mockResolvedValue(0);

const result = await action.execute();

expect(execSpy).toHaveBeenCalledWith(expect.not.stringContaining('/DeployReportPath:'));
expect(result.reportPath).toBeUndefined();
expect(result.scriptPath).toBeUndefined();
});

it('reuses a report path supplied by the caller instead of injecting one', async () => {
const action = new AzureSqlAction(getPublishInputs(true, '/DeployReportPath:"custom.xml"'));
jest.spyOn(AzureSqlActionHelper, 'getSqlPackagePath').mockResolvedValue('SqlPackage.exe');
const execSpy = jest.spyOn(exec, 'exec').mockResolvedValue(0);

const result = await action.execute();

expect(result.reportPath).toBe('custom.xml');
const command = execSpy.mock.calls[0][0] as string;
expect(command.match(/\/DeployReportPath:/g)).toHaveLength(1);
});

it('does not inject capture arguments for a non-Publish action', async () => {
const inputs = getPublishInputs(true);
inputs.sqlpackageAction = SqlPackageAction.Script;
const action = new AzureSqlAction(inputs);
jest.spyOn(AzureSqlActionHelper, 'getSqlPackagePath').mockResolvedValue('SqlPackage.exe');
const execSpy = jest.spyOn(exec, 'exec').mockResolvedValue(0);

const result = await action.execute();

expect(execSpy).toHaveBeenCalledWith(expect.not.stringContaining('/DeployReportPath:'));
expect(result.reportPath).toBeUndefined();
});
});

it('throws if SqlPackage.exe fails to publish dacpac', async () => {
let inputs = getInputs(ActionType.DacpacAction) as IDacpacActionInputs;
let action = new AzureSqlAction(inputs);
Expand Down
59 changes: 59 additions & 0 deletions __tests__/DeploymentReport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as fs from 'fs';
import * as path from 'path';
import { parseDeploymentReport } from '../src/DeploymentReport';

describe('DeploymentReport tests', () => {

const fixture = fs.readFileSync(path.join(__dirname, '..', '__testdata__', 'deployReport.xml'), 'utf8');

it('parses every create, alter, and drop operation', () => {
const report = parseDeploymentReport(fixture);
expect(report.operations).toHaveLength(4);
});

it('captures the operation name, object, and friendly type', () => {
const report = parseDeploymentReport(fixture);
expect(report.operations).toContainEqual({ operation: 'Create', object: '[dbo].[Reactions]', type: 'Table' });
expect(report.operations).toContainEqual({ operation: 'Create', object: '[dbo].[GetUserMentions]', type: 'Procedure' });
expect(report.operations).toContainEqual({ operation: 'Alter', object: '[dbo].[Messages]', type: 'Table' });
expect(report.operations).toContainEqual({ operation: 'Drop', object: '[dbo].[OldAudit]', type: 'Table' });
});

it('parses data-loss alerts', () => {
const report = parseDeploymentReport(fixture);
expect(report.alerts).toHaveLength(1);
expect(report.alerts[0].kind).toBe('DataIssue');
expect(report.alerts[0].detail).toContain('[dbo].[OldAudit]');
});

it('handles a report with a single operation that is not an array', () => {
const xml = `<?xml version="1.0"?><DeploymentReport><Operations><Operation Name="Create"><Item Value="[dbo].[Only]" Type="SqlTable" /></Operation></Operations></DeploymentReport>`;
const report = parseDeploymentReport(xml);
expect(report.operations).toEqual([{ operation: 'Create', object: '[dbo].[Only]', type: 'Table' }]);
});

it('returns an empty report when there are no operations', () => {
const xml = `<?xml version="1.0"?><DeploymentReport></DeploymentReport>`;
const report = parseDeploymentReport(xml);
expect(report.operations).toHaveLength(0);
expect(report.alerts).toHaveLength(0);
});

it('returns an empty report for blank input', () => {
const report = parseDeploymentReport(' ');
expect(report.operations).toHaveLength(0);
expect(report.alerts).toHaveLength(0);
});

it('returns an empty report for unparseable input', () => {
const report = parseDeploymentReport('this is not xml <<<');
expect(report.operations).toHaveLength(0);
expect(report.alerts).toHaveLength(0);
});

it('expands a multi-word type name into a friendly label', () => {
const xml = `<?xml version="1.0"?><DeploymentReport><Operations><Operation Name="Create"><Item Value="[dbo].[PK]" Type="SqlPrimaryKeyConstraint" /></Operation></Operations></DeploymentReport>`;
const report = parseDeploymentReport(xml);
expect(report.operations[0].type).toBe('Primary Key Constraint');
});
});
131 changes: 131 additions & 0 deletions __tests__/DeploymentSummary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { buildSummary, SummaryContext, SUMMARY_MARKER } from '../src/DeploymentSummary';
import { DeploymentReport } from '../src/DeploymentReport';

describe('DeploymentSummary tests', () => {

const baseContext: SummaryContext = {
action: 'Publish',
source: 'Database.dacpac',
server: 'myserver.database.windows.net',
database: 'mydb'
};

const report: DeploymentReport = {
operations: [
{ operation: 'Create', object: '[dbo].[Reactions]', type: 'Table' },
{ operation: 'Alter', object: '[dbo].[Messages]', type: 'Table' },
{ operation: 'Drop', object: '[dbo].[OldAudit]', type: 'Table' }
],
alerts: [
{ kind: 'DataIssue', detail: '[dbo].[OldAudit] will be dropped.' }
]
};

it('renders a headline with the action, source, and redacted target', () => {
const markdown = buildSummary(baseContext);
expect(markdown).toContain('✅ **Published** `Database.dacpac` → `myserver.database.windows.net / mydb`');
});

it('summarizes the change counts and data loss in the headline', () => {
const markdown = buildSummary({ ...baseContext, report });
expect(markdown).toContain('**3 changes** (1 created · 1 altered · 1 dropped) · ⚠️ 1 data-loss warning');
});

it('renders the metadata table', () => {
const markdown = buildSummary({ ...baseContext, report });
expect(markdown).toContain('| **Action** | Publish |');
expect(markdown).toContain('| **Target** | `myserver.database.windows.net / mydb` |');
});

it('renders the duration when provided', () => {
const markdown = buildSummary({ ...baseContext, report, durationMs: 3200 });
expect(markdown).toContain('| **Duration** | 3.2s |');
});

it('renders the triggering user and commit when provided', () => {
const markdown = buildSummary({ ...baseContext, report, actor: 'octocat', commit: 'abc1234' });
expect(markdown).toContain('| **Triggered by** | @octocat |');
expect(markdown).toContain('| **Commit** | `abc1234` |');
});

it('renders the deployment options when provided', () => {
const markdown = buildSummary({ ...baseContext, report, options: '/p:DropObjectsNotInSource=true' });
expect(markdown).toContain('| **Options** | `/p:DropObjectsNotInSource=true` |');
});

it('renders the by-type and by-schema breakdowns', () => {
const markdown = buildSummary({ ...baseContext, report });
expect(markdown).toContain('**By type:** Table ×3');
expect(markdown).toContain('**By schema:** dbo ×3');
});

it('renders a collapsible section per change category', () => {
const markdown = buildSummary({ ...baseContext, report });
expect(markdown).toContain('<summary>➕ Created (1)</summary>');
expect(markdown).toContain('<summary>🔄 Altered (1)</summary>');
expect(markdown).toContain('<summary>🗑️ Dropped (1)</summary>');
expect(markdown).toContain('| `[dbo].[Reactions]` | Table |');
});

it('escapes pipe characters in object names so the table is not broken', () => {
const markdown = buildSummary({ ...baseContext, report: { operations: [{ operation: 'Create', object: '[dbo].[Odd|Name]', type: 'Table' }], alerts: [] } });
expect(markdown).toContain('[dbo].[Odd\\|Name]');
});

it('renders a data-loss callout when alerts are present', () => {
const markdown = buildSummary({ ...baseContext, report });
expect(markdown).toContain('### ⚠️ Possible data loss (1)');
expect(markdown).toContain('> - [dbo].[OldAudit] will be dropped.');
});

it('omits the data-loss callout when there are no alerts', () => {
const markdown = buildSummary({ ...baseContext, report: { operations: report.operations, alerts: [] } });
expect(markdown).not.toContain('Possible data loss');
});

it('renders a no-changes message for an empty report', () => {
const markdown = buildSummary({ ...baseContext, report: { operations: [], alerts: [] } });
expect(markdown).toContain('no schema changes');
expect(markdown).toContain('### 📦 Changes');
});

it('truncates a section beyond the row limit', () => {
const operations = Array.from({ length: 150 }, (_, index) => ({ operation: 'Create', object: `[dbo].[Table${index}]`, type: 'Table' }));
const markdown = buildSummary({ ...baseContext, report: { operations, alerts: [] } });
expect(markdown).toContain('_…and 50 more_');
});

it('embeds the deployment script in a collapsible block with batch and size counts', () => {
const markdown = buildSummary({ ...baseContext, script: 'CREATE TABLE [dbo].[Reactions] (Id INT);\nGO' });
expect(markdown).toContain('📄 Deployment T-SQL script · 1 batch ·');
expect(markdown).toContain('CREATE TABLE [dbo].[Reactions] (Id INT);');
});

it('truncates a very large deployment script', () => {
const markdown = buildSummary({ ...baseContext, script: 'A'.repeat(40000) });
expect(markdown).toContain('Script truncated for display');
});

it('strips a leading byte order mark from the script', () => {
const markdown = buildSummary({ ...baseContext, script: '\uFEFFCREATE TABLE [dbo].[Reactions] (Id INT);' });
expect(markdown).not.toContain('\uFEFF');
expect(markdown).toContain('CREATE TABLE [dbo].[Reactions] (Id INT);');
});

it('renders a run link in the footer when provided', () => {
const markdown = buildSummary({ ...baseContext, report, runUrl: 'https://github.com/o/r/actions/runs/1' });
expect(markdown).toContain('Generated by <b>Azure SQL Deploy</b>');
expect(markdown).toContain('<a href="https://github.com/o/r/actions/runs/1">View run</a>');
});

it('ends with the sticky comment marker', () => {
const markdown = buildSummary({ ...baseContext, report });
expect(markdown.replace(/\s+$/, '').endsWith(SUMMARY_MARKER)).toBe(true);
});

it('never includes a password even if one is present in the context object', () => {
const markdown = buildSummary({ ...baseContext, report, options: '/p:DropObjectsNotInSource=true' });
expect(markdown).not.toContain('Password');
expect(markdown).not.toContain('placeholder');
});
});
Loading
Loading