Merge pull request #37 from mikepenz/feature/fail_on_error

Introduce configuration to fail on errors
This commit is contained in:
Mike Penz
2020-10-18 11:17:39 +02:00
committed by GitHub
6 changed files with 185 additions and 94 deletions
+19 -3
View File
@@ -32,13 +32,14 @@
### What's included 🚀 ### What's included 🚀
- Super simple integration - Super simple integration
- even on huge repositories with hundreds of tags - ...even on huge repositories with hundreds of tags
- Parallel releases support - Parallel releases support
- Blazingly fast execution - Blazingly fast execution
- Supports any git project - Supports any git project
- Highly flexible configuration - Highly flexible configuration
- Lightweight - Lightweight
- Supports any branch - Supports any branch
- Rich build log output
------- -------
@@ -70,12 +71,13 @@ ${{steps.build_changelog.outputs.changelog}}
A full set list of possible output values for this action. A full set list of possible output values for this action.
| **Output** | **Description** | | **Output** | **Description** |
|---------------------|-------------------------------------------------------------------------------------| |---------------------|---------------------------------------------------------------------------------------------------------------------------|
| `outputs.changelog` | The built release changelog built from the merged pull requests | | `outputs.changelog` | The built release changelog built from the merged pull requests |
| `outputs.owner` | Specifies the owner of the repository processed | | `outputs.owner` | Specifies the owner of the repository processed |
| `outputs.repo` | Describes the repository name, which was processed | | `outputs.repo` | Describes the repository name, which was processed |
| `outputs.fromTag` | Defines the `fromTag` which describes the lower bound to process pull requests for | | `outputs.fromTag` | Defines the `fromTag` which describes the lower bound to process pull requests for |
| `outputs.toTag` | Defines the `toTag` which describes the upper bound to process pull request for | | `outputs.toTag` | Defines the `toTag` which describes the upper bound to process pull request for |
| `outputs.failed` | Defines if there was an issue with the action run, and the changelog may not have been generated correctly. [true, false] |
## Customization 🖍️ ## Customization 🖍️
@@ -152,7 +154,21 @@ For advanced usecases additional settings can be provided to the action
token: ${{ secrets.PAT }} token: ${{ secrets.PAT }}
``` ```
💡 `ignorePreReleases` will be ignored, if `fromTag` is specified. `${{ secrets.GITHUB_TOKEN }}` only grants rights to the current repository, for other repos please use a PAT (Personal Access Token). 💡 All input values are optional. It is only required to privde the `token` either via the input, or as `env` variable.
| **Input** | **Description** |
|-------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| configuration | Relative path, to the `configuration.json` file, providing additional configurations |
| owner | The owner of the repository to generate the changelog for |
| repo | Name of the repository we want to process |
| fromTag | Defines the 'start' from where the changelog will consider merged pull requests |
| toTag | Defines until which tag the changelog will consider merged pull requests |
| path | Allows to specify an alternative sub directory, to use as base |
| token | Alternative config to specify token. You should prefer `env.GITHUB_TOKEN` instead though |
| ignorePreReleases | Allows to ignore pre-releases for changelog generation (E.g. for 1.0.1... 1.0.0-rc02 <- ignore, 1.0.0 <- pick). Only used if `fromTag` was not specified. Default: false |
| failOnError | Defines if the action will result in a build failure, if problems occurred. Default: false |
💡 `${{ secrets.GITHUB_TOKEN }}` only grants rights to the current repository, for other repos please use a PAT (Personal Access Token).
### PR Template placeholders ### PR Template placeholders
+14 -8
View File
@@ -1,5 +1,5 @@
import {ReleaseNotes} from '../src/releaseNotes' import {ReleaseNotes} from '../src/releaseNotes'
import {readConfiguration} from '../src/utils' import { resolveConfiguration } from '../src/utils';
// shows how the runner will run a javascript action with env / stdout protocol // shows how the runner will run a javascript action with env / stdout protocol
/* /*
@@ -17,31 +17,33 @@ test('test runs', () => {
it('Should have empty changelog (tags)', async () => { it('Should have empty changelog (tags)', async () => {
jest.setTimeout(180000) jest.setTimeout(180000)
const configuration = readConfiguration('configs/configuration.json')!! const configuration = resolveConfiguration('', 'configs/configuration.json')
const releaseNotes = new ReleaseNotes({ const releaseNotes = new ReleaseNotes({
owner: 'mikepenz', owner: 'mikepenz',
repo: 'release-changelog-builder-action', repo: 'release-changelog-builder-action',
fromTag: 'v0.0.1', fromTag: 'v0.0.1',
toTag: 'v0.0.2', toTag: 'v0.0.2',
ignorePreReleases: false, ignorePreReleases: false,
failOnError: false,
configuration: configuration configuration: configuration
}) })
const changeLog = await releaseNotes.pull() const changeLog = await releaseNotes.pull()
console.log(changeLog) console.log(changeLog)
expect(changeLog).toStrictEqual(`- no changes`) expect(changeLog).toStrictEqual(null)
}) })
it('Should match generated changelog (tags)', async () => { it('Should match generated changelog (tags)', async () => {
jest.setTimeout(180000) jest.setTimeout(180000)
const configuration = readConfiguration('configs/configuration.json')!! const configuration = resolveConfiguration('', 'configs/configuration.json')
const releaseNotes = new ReleaseNotes({ const releaseNotes = new ReleaseNotes({
owner: 'mikepenz', owner: 'mikepenz',
repo: 'release-changelog-builder-action', repo: 'release-changelog-builder-action',
fromTag: 'v0.0.1', fromTag: 'v0.0.1',
toTag: 'v0.0.3', toTag: 'v0.0.3',
ignorePreReleases: false, ignorePreReleases: false,
failOnError: false,
configuration: configuration configuration: configuration
}) })
@@ -58,13 +60,14 @@ it('Should match generated changelog (tags)', async () => {
it('Should match generated changelog (unspecified fromTag)', async () => { it('Should match generated changelog (unspecified fromTag)', async () => {
jest.setTimeout(180000) jest.setTimeout(180000)
const configuration = readConfiguration('configs/configuration.json')!! const configuration = resolveConfiguration('', 'configs/configuration.json')
const releaseNotes = new ReleaseNotes({ const releaseNotes = new ReleaseNotes({
owner: 'mikepenz', owner: 'mikepenz',
repo: 'release-changelog-builder-action', repo: 'release-changelog-builder-action',
fromTag: null, fromTag: null,
toTag: 'v0.0.3', toTag: 'v0.0.3',
ignorePreReleases: false, ignorePreReleases: false,
failOnError: false,
configuration: configuration configuration: configuration
}) })
@@ -81,13 +84,14 @@ it('Should match generated changelog (unspecified fromTag)', async () => {
it('Should match generated changelog (refs)', async () => { it('Should match generated changelog (refs)', async () => {
jest.setTimeout(180000) jest.setTimeout(180000)
const configuration = readConfiguration('configs_test/configuration_all_placeholders.json')!! const configuration = resolveConfiguration('', 'configs_test/configuration_all_placeholders.json')
const releaseNotes = new ReleaseNotes({ const releaseNotes = new ReleaseNotes({
owner: 'mikepenz', owner: 'mikepenz',
repo: 'release-changelog-builder-action', repo: 'release-changelog-builder-action',
fromTag: '5ec7a2d86fe9f43fdd38d5e254a1117c8a51b4c3', fromTag: '5ec7a2d86fe9f43fdd38d5e254a1117c8a51b4c3',
toTag: 'fa3788c8c4b3373ef8424ce3eb008a5cd07cc5aa', toTag: 'fa3788c8c4b3373ef8424ce3eb008a5cd07cc5aa',
ignorePreReleases: false, ignorePreReleases: false,
failOnError: false,
configuration: configuration configuration: configuration
}) })
@@ -112,13 +116,14 @@ nhoelzl
it('Should match ordered ASC', async () => { it('Should match ordered ASC', async () => {
jest.setTimeout(180000) jest.setTimeout(180000)
const configuration = readConfiguration('configs_test/configuration_asc.json')!! const configuration = resolveConfiguration('', 'configs_test/configuration_asc.json')
const releaseNotes = new ReleaseNotes({ const releaseNotes = new ReleaseNotes({
owner: 'mikepenz', owner: 'mikepenz',
repo: 'release-changelog-builder-action', repo: 'release-changelog-builder-action',
fromTag: 'v0.3.0', fromTag: 'v0.3.0',
toTag: 'v0.5.0', toTag: 'v0.5.0',
ignorePreReleases: false, ignorePreReleases: false,
failOnError: false,
configuration: configuration configuration: configuration
}) })
@@ -130,13 +135,14 @@ it('Should match ordered ASC', async () => {
it('Should match ordered DESC', async () => { it('Should match ordered DESC', async () => {
jest.setTimeout(180000) jest.setTimeout(180000)
const configuration = readConfiguration('configs_test/configuration_desc.json')!! const configuration = resolveConfiguration('', 'configs_test/configuration_desc.json')
const releaseNotes = new ReleaseNotes({ const releaseNotes = new ReleaseNotes({
owner: 'mikepenz', owner: 'mikepenz',
repo: 'release-changelog-builder-action', repo: 'release-changelog-builder-action',
fromTag: 'v0.3.0', fromTag: 'v0.3.0',
toTag: 'v0.5.0', toTag: 'v0.5.0',
ignorePreReleases: false, ignorePreReleases: false,
failOnError: false,
configuration: configuration configuration: configuration
}) })
+3
View File
@@ -20,6 +20,9 @@ inputs:
ignorePreReleases: ignorePreReleases:
description: 'Defines if the action will only use full releases to compare against (Only used if fromTag is not defined). E.g. for 1.0.1... 1.0.0-rc02 <- ignore, 1.0.0 <- pick' description: 'Defines if the action will only use full releases to compare against (Only used if fromTag is not defined). E.g. for 1.0.1... 1.0.0-rc02 <- ignore, 1.0.0 <- pick'
default: "false" default: "false"
failOnError:
description: 'Defines if the action should result in a build failure, if an error was discovered'
default: "false"
token: token:
description: 'Defines the token to use to execute the git API requests with, uses `env.GITHUB_TOKEN` by default' description: 'Defines the token to use to execute the git API requests with, uses `env.GITHUB_TOKEN` by default'
outputs: outputs:
+32 -56
View File
@@ -1,52 +1,42 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {readConfiguration} from './utils' import {
failOrError,
retrieveRepositoryPath,
resolveConfiguration
} from './utils'
import {ReleaseNotes} from './releaseNotes' import {ReleaseNotes} from './releaseNotes'
import {createCommandManager} from './gitHelper' import {createCommandManager} from './gitHelper'
import * as github from '@actions/github' import * as github from '@actions/github'
import * as path from 'path'
import {DefaultConfiguration} from './configuration' import {DefaultConfiguration} from './configuration'
async function run(): Promise<void> { async function run(): Promise<void> {
core.setOutput('failed', false) // mark the action not failed by default
core.startGroup(`📘 Reading input values`) core.startGroup(`📘 Reading input values`)
try { try {
let githubWorkspacePath = process.env['GITHUB_WORKSPACE'] // read in path specification, resolve github workspace, and repo path
if (!githubWorkspacePath) { const inputPath = core.getInput('path')
throw new Error('GITHUB_WORKSPACE not defined') const repositoryPath = retrieveRepositoryPath(inputPath)
}
githubWorkspacePath = path.resolve(githubWorkspacePath)
core.debug(`GITHUB_WORKSPACE = '${githubWorkspacePath}'`)
let repositoryPath = core.getInput('path') || '.'
repositoryPath = path.resolve(githubWorkspacePath, repositoryPath)
core.debug(`repositoryPath = '${repositoryPath}'`)
// read in configuration file if possible
const configurationFile: string = core.getInput('configuration') const configurationFile: string = core.getInput('configuration')
let configuration = DefaultConfiguration const configuration = resolveConfiguration(
if (configurationFile) { repositoryPath,
const configurationPath = path.resolve(
githubWorkspacePath,
configurationFile configurationFile
) )
core.debug(`configurationPath = '${configurationPath}'`)
const providedConfiguration = readConfiguration(configurationPath)
if (!providedConfiguration) {
core.info(
`⚠️ Configuration provided, but it couldn't be found, or failed to parse. Fallback to Defaults`
)
} else {
configuration = providedConfiguration
}
}
// read in repository inputs
const token = core.getInput('token') const token = core.getInput('token')
let owner = core.getInput('owner') const owner = core.getInput('owner') ?? github.context.repo.owner
let repo = core.getInput('repo') const repo = core.getInput('repo') ?? github.context.repo.repo
// read in from, to tag inputs
const fromTag = core.getInput('fromTag') const fromTag = core.getInput('fromTag')
let toTag = core.getInput('toTag') let toTag = core.getInput('toTag')
// read in flags
const ignorePreReleases = core.getInput('ignorePreReleases') === 'true'
const failOnError = core.getInput('failOnError') === 'true'
const ignorePreReleases = core.getInput('ignorePreReleases') // ensure to resolve the toTag if it was not provided
if (!toTag) { if (!toTag) {
// if not specified try to retrieve tag from github.context.ref // if not specified try to retrieve tag from github.context.ref
if (github.context.ref.startsWith('refs/tags/')) { if (github.context.ref.startsWith('refs/tags/')) {
@@ -65,28 +55,8 @@ async function run(): Promise<void> {
} }
} }
if (!owner || !repo) {
// Qualified repository
const qualifiedRepository =
core.getInput('repository') ||
`${github.context.repo.owner}/${github.context.repo.repo}`
core.debug(`qualified repository = '${qualifiedRepository}'`)
const splitRepository = qualifiedRepository.split('/')
if (
splitRepository.length !== 2 ||
!splitRepository[0] ||
!splitRepository[1]
) {
throw new Error(
`Invalid repository '${qualifiedRepository}'. Expected format {owner}/{repo}.`
)
}
owner = splitRepository[0]
repo = splitRepository[1]
}
if (!owner) { if (!owner) {
core.error(`💥 Missing or couldn't resolve 'owner'`) failOrError(`💥 Missing or couldn't resolve 'owner'`, failOnError)
return return
} else { } else {
core.setOutput('owner', owner) core.setOutput('owner', owner)
@@ -94,7 +64,7 @@ async function run(): Promise<void> {
} }
if (!repo) { if (!repo) {
core.error(`💥 Missing or couldn't resolve 'owner'`) failOrError(`💥 Missing or couldn't resolve 'owner'`, failOnError)
return return
} else { } else {
core.setOutput('repo', repo) core.setOutput('repo', repo)
@@ -102,7 +72,7 @@ async function run(): Promise<void> {
} }
if (!toTag) { if (!toTag) {
core.error(`💥 Missing or couldn't resolve 'toTag'`) failOrError(`💥 Missing or couldn't resolve 'toTag'`, failOnError)
return return
} else { } else {
core.setOutput('toTag', toTag) core.setOutput('toTag', toTag)
@@ -115,11 +85,17 @@ async function run(): Promise<void> {
repo, repo,
fromTag, fromTag,
toTag, toTag,
ignorePreReleases: ignorePreReleases === 'true', ignorePreReleases,
failOnError,
configuration configuration
}) })
core.setOutput('changelog', await releaseNotes.pull(token)) core.setOutput(
'changelog',
(await releaseNotes.pull(token)) ??
configuration.empty_template ??
DefaultConfiguration.empty_template
)
} catch (error) { } catch (error) {
core.setFailed(error.message) core.setFailed(error.message)
} }
+31 -11
View File
@@ -5,6 +5,7 @@ import {buildChangelog} from './transform'
import * as core from '@actions/core' import * as core from '@actions/core'
import {Tags} from './tags' import {Tags} from './tags'
import {Configuration, DefaultConfiguration} from './configuration' import {Configuration, DefaultConfiguration} from './configuration'
import {failOrError} from './utils'
export interface ReleaseNotesOptions { export interface ReleaseNotesOptions {
owner: string // the owner of the repository owner: string // the owner of the repository
@@ -12,18 +13,26 @@ export interface ReleaseNotesOptions {
fromTag: string | null // the tag/ref to start from fromTag: string | null // the tag/ref to start from
toTag: string // the tag/ref up to toTag: string // the tag/ref up to
ignorePreReleases: boolean // defines if we should ignore any pre-releases for matching, only relevant if fromTag is null ignorePreReleases: boolean // defines if we should ignore any pre-releases for matching, only relevant if fromTag is null
failOnError: boolean // defines if we should fail the action in case of an error
configuration: Configuration // the configuration as defined in `configuration.ts` configuration: Configuration // the configuration as defined in `configuration.ts`
} }
export class ReleaseNotes { export class ReleaseNotes {
constructor(private options: ReleaseNotesOptions) {} constructor(private options: ReleaseNotesOptions) {}
async pull(token?: string): Promise<string> { async pull(token?: string): Promise<string | null> {
const octokit = new Octokit({ const octokit = new Octokit({
auth: `token ${token || process.env.GITHUB_TOKEN}` auth: `token ${token || process.env.GITHUB_TOKEN}`
}) })
const {owner, repo, toTag, ignorePreReleases, configuration} = this.options const {
owner,
repo,
toTag,
ignorePreReleases,
failOnError,
configuration
} = this.options
if (!this.options.fromTag) { if (!this.options.fromTag) {
core.startGroup(`🔖 Resolve previous tag`) core.startGroup(`🔖 Resolve previous tag`)
@@ -39,10 +48,11 @@ export class ReleaseNotes {
DefaultConfiguration.max_tags_to_fetch DefaultConfiguration.max_tags_to_fetch
) )
if (previousTag == null) { if (previousTag == null) {
core.error(`💥 Unable to retrieve previous tag given ${toTag}`) failOrError(
return ( `💥 Unable to retrieve previous tag given ${toTag}`,
configuration.empty_template ?? DefaultConfiguration.empty_template failOnError
) )
return null
} }
this.options.fromTag = previousTag.name this.options.fromTag = previousTag.name
core.debug(`fromTag resolved via previousTag as: ${previousTag.name}`) core.debug(`fromTag resolved via previousTag as: ${previousTag.name}`)
@@ -50,8 +60,8 @@ export class ReleaseNotes {
} }
if (!this.options.fromTag) { if (!this.options.fromTag) {
core.error(`💥 Missing or couldn't resolve 'fromTag'`) failOrError(`💥 Missing or couldn't resolve 'fromTag'`, failOnError)
return configuration.empty_template ?? DefaultConfiguration.empty_template return null
} else { } else {
core.setOutput('fromTag', this.options.fromTag) core.setOutput('fromTag', this.options.fromTag)
} }
@@ -62,7 +72,7 @@ export class ReleaseNotes {
if (mergedPullRequests.length === 0) { if (mergedPullRequests.length === 0) {
core.warning(`⚠️ No pull requests found`) core.warning(`⚠️ No pull requests found`)
return configuration.empty_template ?? DefaultConfiguration.empty_template return null
} }
core.startGroup('📦 Build changelog') core.startGroup('📦 Build changelog')
@@ -74,7 +84,14 @@ export class ReleaseNotes {
private async getMergedPullRequests( private async getMergedPullRequests(
octokit: Octokit octokit: Octokit
): Promise<PullRequestInfo[]> { ): Promise<PullRequestInfo[]> {
const {owner, repo, fromTag, toTag, configuration} = this.options const {
owner,
repo,
fromTag,
toTag,
failOnError,
configuration
} = this.options
core.info(`️ Comparing ${owner}/${repo} - '${fromTag}...${toTag}'`) core.info(`️ Comparing ${owner}/${repo} - '${fromTag}...${toTag}'`)
const commitsApi = new Commits(octokit) const commitsApi = new Commits(octokit)
@@ -82,11 +99,14 @@ export class ReleaseNotes {
try { try {
commits = await commitsApi.getDiff(owner, repo, fromTag!!, toTag) commits = await commitsApi.getDiff(owner, repo, fromTag!!, toTag)
} catch (error) { } catch (error) {
core.error(`💥 Failed to retrieve - Invalid tag? - Because of: ${error}`) failOrError(
`💥 Failed to retrieve - Invalid tag? - Because of: ${error}`,
failOnError
)
return [] return []
} }
if (commits.length === 0) { if (commits.length === 0) {
core.warning(`💥 No commits found between - ${fromTag}...${toTag}`) core.warning(`⚠️ No commits found between - ${fromTag}...${toTag}`)
return [] return []
} }
+78 -8
View File
@@ -1,7 +1,71 @@
import * as fs from 'fs' import * as fs from 'fs'
import {Configuration} from './configuration' import {Configuration, DefaultConfiguration} from './configuration'
import * as core from '@actions/core'
import * as path from 'path'
export function readConfiguration(filename: string): Configuration | null { /**
* Resolves the repository path, relatively to the GITHUB_WORKSPACE
*/
export function retrieveRepositoryPath(providedPath: string): string {
let githubWorkspacePath = process.env['GITHUB_WORKSPACE']
if (!githubWorkspacePath) {
throw new Error('GITHUB_WORKSPACE not defined')
}
githubWorkspacePath = path.resolve(githubWorkspacePath)
core.debug(`GITHUB_WORKSPACE = '${githubWorkspacePath}'`)
let repositoryPath = providedPath || '.'
repositoryPath = path.resolve(githubWorkspacePath, repositoryPath)
core.debug(`repositoryPath = '${repositoryPath}'`)
return repositoryPath
}
/**
* Will automatically either report the message to the log, or mark the action as failed. Additionally defining the output failed, allowing it to be read in by other actions
*/
export function failOrError(
message: string | Error,
failOnError: boolean
): void {
// if we report any failure, consider the action to have failed, may not make the build fail
core.setOutput('failed', true)
if (failOnError) {
core.setFailed(message)
} else {
core.error(message)
}
}
/**
* Retrieves the configuration given the file path, if not found it will fallback to the `DefaultConfiguration`
*/
export function resolveConfiguration(
githubWorkspacePath: string,
configurationFile: string
): Configuration {
let configuration = DefaultConfiguration
if (configurationFile) {
const configurationPath = path.resolve(
githubWorkspacePath,
configurationFile
)
core.debug(`configurationPath = '${configurationPath}'`)
const providedConfiguration = readConfiguration(configurationPath)
if (!providedConfiguration) {
core.info(
`⚠️ Configuration provided, but it couldn't be found, or failed to parse. Fallback to Defaults`
)
} else {
configuration = providedConfiguration
}
}
return configuration
}
/**
* Reads in the configuration from the JSON file
*/
function readConfiguration(filename: string): Configuration | null {
try { try {
const rawdata = fs.readFileSync(filename, 'utf8') const rawdata = fs.readFileSync(filename, 'utf8')
const configurationJSON: Configuration = JSON.parse(rawdata) const configurationJSON: Configuration = JSON.parse(rawdata)
@@ -11,25 +75,31 @@ export function readConfiguration(filename: string): Configuration | null {
} }
} }
export function directoryExistsSync(path: string, required?: boolean): boolean { /**
if (!path) { * Checks if a given directory exists
*/
export function directoryExistsSync(
inputPath: string,
required?: boolean
): boolean {
if (!inputPath) {
throw new Error("Arg 'path' must not be empty") throw new Error("Arg 'path' must not be empty")
} }
let stats: fs.Stats let stats: fs.Stats
try { try {
stats = fs.statSync(path) stats = fs.statSync(inputPath)
} catch (error) { } catch (error) {
if (error.code === 'ENOENT') { if (error.code === 'ENOENT') {
if (!required) { if (!required) {
return false return false
} }
throw new Error(`Directory '${path}' does not exist`) throw new Error(`Directory '${inputPath}' does not exist`)
} }
throw new Error( throw new Error(
`Encountered an error when checking whether path '${path}' exists: ${error.message}` `Encountered an error when checking whether path '${inputPath}' exists: ${error.message}`
) )
} }
@@ -39,5 +109,5 @@ export function directoryExistsSync(path: string, required?: boolean): boolean {
return false return false
} }
throw new Error(`Directory '${path}' does not exist`) throw new Error(`Directory '${inputPath}' does not exist`)
} }