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
16 changes: 15 additions & 1 deletion docs/docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,21 @@ Configure diagnostic warnings emitted during build when using `@github-actions-w

- `"off"` - Suppress the diagnostic entirely
- `"warn"` - Emit as a warning (default)
- `"error"` - Upgrade to an error (fails the build)
- `"error"` - Upgrade to an error. Causes the CLI to exit with a non-zero status code at the end of the build (see [`failOnError`](#failonerror) below).

#### `failOnError`

```json
{
"diagnostics": {
"failOnError": true
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some devs prefer to configure their tools to fail on warnings. I wonder if this should be:

"maxSeverity": "info" // fails on warnings and above

Or

"failOnSeverity": "warn" // fails on warnings and above

}
}
```

When `true` (the default), the CLI exits with status code `1` after the build if any diagnostic at `error` or `fatal` severity (after applying `rules`) was emitted. When `false`, the CLI always exits `0` regardless of emitted diagnostics — matching pre-2.6.0 behaviour.

The non-zero exit is useful in pre-commit hooks and CI steps where stdout may be hidden — it ensures broken workflows are caught instead of silently committed. To stop an individual diagnostic from failing the build without disabling the feature globally, downgrade it to `warn` (or `off`) via `rules`, or suppress it at the call site with `suppressWarnings` / `Diagnostics.suppress`.

#### Exclude Patterns

Expand Down
16 changes: 16 additions & 0 deletions docs/docs/guides/typed-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ In `wac.config.json`:
}
```

## Build Exit Code

The CLI exits with status code `1` if any diagnostic at `error` or `fatal` severity was emitted during the build (after applying configured `rules`). Lower-severity diagnostics (`trace`, `debug`, `info`, `warning`) do not affect the exit code.

This is especially useful inside a pre-commit hook or CI step where stdout may be hidden — a non-zero exit code ensures broken workflows are caught instead of silently committed. To stop a specific code from failing the build, downgrade it to `warn` (or `off`) via the `rules` configuration above, or suppress it at the call site with `suppressWarnings` / `Diagnostics.suppress`.

To preserve the pre-2.6.0 behaviour where the CLI always exited `0` regardless of emitted diagnostics, set `failOnError` to `false`:

```json
{
"diagnostics": {
"failOnError": false
}
}
```

## Requesting New Actions

If there's an action you'd like to see added, [open an issue](https://github.com/emmanuelnk/github-actions-workflow-ts/issues/new) or see [Adding Actions](/docs/contributing/adding-actions) to contribute it yourself.
25 changes: 25 additions & 0 deletions packages/cli/src/commands/__mocks__/error-emitting.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import {
Workflow,
NormalJob,
Step,
Context,
Diagnostics,
} from '@github-actions-workflow-ts/lib'

// Emit an error-severity diagnostic at import time to simulate a wac file
// that detects a problem during workflow construction.
const reporter = Context.getGlobalWacContext()?.diagnostics
reporter?.emit({
severity: Diagnostics.DiagnosticSeverity.ERROR,
code: 'simulated-error',
message: 'simulated error from a wac file',
})

const job = new NormalJob('Test', { 'runs-on': 'ubuntu-latest' }).addSteps([
new Step({ name: 'Noop', run: 'true' }),
])

export const test = new Workflow('error-emitting-mock', {
name: 'ErrorEmittingMock',
on: { workflow_dispatch: {} },
}).addJob(job)
24 changes: 24 additions & 0 deletions packages/cli/src/commands/__mocks__/warning-emitting.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
Workflow,
NormalJob,
Step,
Context,
Diagnostics,
} from '@github-actions-workflow-ts/lib'

// Emit only a warning to confirm sub-error diagnostics do not change exit code.
const reporter = Context.getGlobalWacContext()?.diagnostics
reporter?.emit({
severity: Diagnostics.DiagnosticSeverity.WARN,
code: 'simulated-warning',
message: 'simulated warning from a wac file',
})

const job = new NormalJob('Test', { 'runs-on': 'ubuntu-latest' }).addSteps([
new Step({ name: 'Noop', run: 'true' }),
])

export const test = new Workflow('warning-emitting-mock', {
name: 'WarningEmittingMock',
on: { workflow_dispatch: {} },
}).addJob(job)
163 changes: 161 additions & 2 deletions packages/cli/src/commands/build.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import { describe, it, expect } from '@jest/globals'
import {
describe,
it,
expect,
afterEach,
beforeEach,
jest,
} from '@jest/globals'
import * as fs from 'fs'
import * as os from 'os'
import * as path from 'path'
import { fileURLToPath } from 'url'
import { importWorkflowFile } from './build.js'
import { Context } from '@github-actions-workflow-ts/lib'
import { generateWorkflowFiles, importWorkflowFile } from './build.js'
import { ConsoleDiagnosticsReporter } from './diagnostics.js'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

const CLI_PACKAGE_ROOT = path.resolve(__dirname, '..', '..')

describe('build integration tests', () => {
describe('importWorkflowFile', () => {
it('should successfully import a .wac.ts file and return workflow exports', async () => {
Expand All @@ -23,4 +36,150 @@ describe('build integration tests', () => {
expect(result.test.workflow.name).toBe('ExampleMockTests')
})
})

describe('error-severity diagnostics flip hasErrors on the reporter', () => {
let consoleErrorSpy: jest.SpiedFunction<typeof console.error>

beforeEach(() => {
consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {})
})

afterEach(() => {
consoleErrorSpy.mockRestore()
Context.__internalSetGlobalContext(undefined as never)
})

it('should set reporter.hasErrors when a wac file emits an error diagnostic', async () => {
const reporter = new ConsoleDiagnosticsReporter({ color: false })
Context.__internalSetGlobalContext({ diagnostics: reporter })

const mockWacPath = path.join(
__dirname,
'__mocks__',
'error-emitting.fixture.ts',
)
await importWorkflowFile(mockWacPath)

expect(reporter.hasErrors).toBe(true)
})

it('should not set reporter.hasErrors when a wac file only emits a warning', async () => {
const reporter = new ConsoleDiagnosticsReporter({ color: false })
Context.__internalSetGlobalContext({ diagnostics: reporter })

const mockWacPath = path.join(
__dirname,
'__mocks__',
'warning-emitting.fixture.ts',
)
await importWorkflowFile(mockWacPath)

expect(reporter.hasErrors).toBe(false)
})
})

describe('generateWorkflowFiles exit-code behaviour', () => {
let consoleSpies: {
log: jest.SpiedFunction<typeof console.log>
error: jest.SpiedFunction<typeof console.error>
}
let originalCwd: string
let originalExitCode: typeof process.exitCode
let tmpDir: string

/**
* Build a temp project containing a single `*.wac.ts` file and (optionally)
* a `wac.config.json`. The wac file imports the library through a relative
* symlink so the dynamic import inside `importWorkflowFile` resolves it.
*/
const setupProject = (opts: {
wacFilename: string
configJson?: Record<string, unknown>
}) => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'wac-it-'))

// Symlink the CLI package's node_modules into the temp project so the
// wac file can resolve '@github-actions-workflow-ts/lib' via dynamic
// import. (The workspace's `lib` lives under packages/cli/node_modules
// when pnpm sets up workspace links, not the repo-root node_modules.)
const cliNodeModules = path.join(CLI_PACKAGE_ROOT, 'node_modules')
fs.symlinkSync(cliNodeModules, path.join(tmpDir, 'node_modules'), 'dir')

const wacSrc = fs.readFileSync(
path.join(__dirname, '__mocks__', opts.wacFilename),
'utf-8',
)
fs.writeFileSync(path.join(tmpDir, 'wf.wac.ts'), wacSrc)

if (opts.configJson) {
fs.writeFileSync(
path.join(tmpDir, 'wac.config.json'),
JSON.stringify(opts.configJson),
)
}

// Output directory the build will write into.
fs.mkdirSync(path.join(tmpDir, '.github', 'workflows'), {
recursive: true,
})
}

beforeEach(() => {
consoleSpies = {
log: jest.spyOn(console, 'log').mockImplementation(() => {}),
error: jest.spyOn(console, 'error').mockImplementation(() => {}),
}
originalCwd = process.cwd()
originalExitCode = process.exitCode
process.exitCode = undefined
})

afterEach(() => {
process.chdir(originalCwd)
process.exitCode = originalExitCode
consoleSpies.log.mockRestore()
consoleSpies.error.mockRestore()
Context.__internalSetGlobalContext(undefined as never)
if (tmpDir) {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})

it('should set process.exitCode to 1 when a wac file emits an error diagnostic', async () => {
setupProject({ wacFilename: 'error-emitting.fixture.ts' })
process.chdir(tmpDir)

await generateWorkflowFiles({})

expect(process.exitCode).toBe(1)
expect(consoleSpies.error).toHaveBeenCalledWith(
expect.stringContaining(
'Build completed with error diagnostics. Exiting with non-zero status code.',
),
)
})

it('should not set process.exitCode when only warnings are emitted', async () => {
setupProject({ wacFilename: 'warning-emitting.fixture.ts' })
process.chdir(tmpDir)

await generateWorkflowFiles({})

expect(process.exitCode).toBeUndefined()
})

it('should not set process.exitCode when failOnError is disabled in wac.config.json', async () => {
setupProject({
wacFilename: 'error-emitting.fixture.ts',
configJson: { diagnostics: { failOnError: false } },
})
process.chdir(tmpDir)

await generateWorkflowFiles({})

expect(process.exitCode).toBeUndefined()
})
})
})
11 changes: 10 additions & 1 deletion packages/cli/src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,9 @@ export const generateWorkflowFiles = async (
// Track created directories to avoid duplicate creation attempts
const createdDirectories = new Set<string>()

const diagnosticsReporter = new ConsoleDiagnosticsReporter()
Context.__internalSetGlobalContext({
diagnostics: new ConsoleDiagnosticsReporter(),
diagnostics: diagnosticsReporter,
diagnosticRules: config.diagnostics?.rules,
})

Expand All @@ -407,4 +408,12 @@ export const generateWorkflowFiles = async (
console.log(
`[github-actions-workflow-ts] Successfully generated ${workflowCount} workflow file(s)`,
)

const failOnError = config.diagnostics?.failOnError ?? true
if (diagnosticsReporter.hasErrors && failOnError) {
console.error(
'[github-actions-workflow-ts] Build completed with error diagnostics. Exiting with non-zero status code.',
)
process.exitCode = 1
}
}
Loading
Loading