@at-blacknight/semantic-release-ci-output 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.eslintrc.yml +1 -0
- package/.github/workflows/ci.yml +58 -0
- package/.github/workflows/publish.yml +28 -0
- package/.releaserc +16 -0
- package/README.md +114 -0
- package/index.js +9 -0
- package/lib/analyzeCommits.js +36 -0
- package/lib/generateNotes.js +46 -0
- package/lib/platform.js +67 -0
- package/lib/verifyRelease.js +37 -0
- package/package.json +47 -0
- package/test/analyzeCommits.test.js +94 -0
- package/test/generateNotes.test.js +121 -0
- package/test/platform.test.js +119 -0
- package/test/verifyRelease.test.js +84 -0
package/.eslintrc.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
extends: standard
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main, alpha, beta]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [main]
|
|
8
|
+
|
|
9
|
+
permissions:
|
|
10
|
+
contents: read
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
test:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
strategy:
|
|
16
|
+
matrix:
|
|
17
|
+
node-version: [20, 22, 24]
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- uses: actions/setup-node@v4
|
|
22
|
+
with:
|
|
23
|
+
node-version: ${{ matrix.node-version }}
|
|
24
|
+
|
|
25
|
+
- run: npm install
|
|
26
|
+
|
|
27
|
+
- run: npm run lint
|
|
28
|
+
|
|
29
|
+
- run: npm test
|
|
30
|
+
|
|
31
|
+
release:
|
|
32
|
+
needs: test
|
|
33
|
+
if: github.event_name == 'push'
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
permissions:
|
|
36
|
+
contents: write
|
|
37
|
+
issues: write
|
|
38
|
+
pull-requests: write
|
|
39
|
+
id-token: write
|
|
40
|
+
packages: write
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/checkout@v4
|
|
43
|
+
with:
|
|
44
|
+
fetch-depth: 0
|
|
45
|
+
|
|
46
|
+
- uses: actions/setup-node@v4
|
|
47
|
+
with:
|
|
48
|
+
node-version: '24'
|
|
49
|
+
registry-url: 'https://registry.npmjs.org'
|
|
50
|
+
|
|
51
|
+
- run: npm install
|
|
52
|
+
|
|
53
|
+
- run: npx semantic-release
|
|
54
|
+
env:
|
|
55
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
56
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
57
|
+
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
|
58
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Publish to GitHub Packages
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [created]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
packages: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish-github:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '24'
|
|
20
|
+
registry-url: 'https://npm.pkg.github.com'
|
|
21
|
+
scope: '@at-blacknight-projects'
|
|
22
|
+
|
|
23
|
+
- run: npm install
|
|
24
|
+
|
|
25
|
+
- run: npm publish
|
|
26
|
+
env:
|
|
27
|
+
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
28
|
+
NPM_CONFIG_REGISTRY: https://npm.pkg.github.com
|
package/.releaserc
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
branches:
|
|
2
|
+
- main
|
|
3
|
+
- name: alpha
|
|
4
|
+
prerelease: true
|
|
5
|
+
- name: beta
|
|
6
|
+
prerelease: true
|
|
7
|
+
dryRun: false
|
|
8
|
+
ci: true
|
|
9
|
+
plugins:
|
|
10
|
+
- "@semantic-release/commit-analyzer"
|
|
11
|
+
- "@semantic-release/release-notes-generator"
|
|
12
|
+
- "@semantic-release/changelog"
|
|
13
|
+
- "@semantic-release/npm"
|
|
14
|
+
- "@semantic-release/github"
|
|
15
|
+
- - "@semantic-release/git"
|
|
16
|
+
- message: "chore(release): ${nextRelease.version} ***NO_CI***\n\n${nextRelease.notes}"
|
package/README.md
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# @at-blacknight/semantic-release-ci-output
|
|
2
|
+
|
|
3
|
+
[semantic-release](https://github.com/semantic-release/semantic-release) plugin that outputs release information to CI platforms. Auto-detects the CI environment and writes variables using the native mechanism for each platform.
|
|
4
|
+
|
|
5
|
+
Works in **dry-run mode** — unlike `@semantic-release/changelog`, the changelog file is written during `generateNotes` which executes in dry-run.
|
|
6
|
+
|
|
7
|
+
## Supported Platforms
|
|
8
|
+
|
|
9
|
+
| Platform | Output Mechanism |
|
|
10
|
+
|----------|-----------------|
|
|
11
|
+
| GitHub Actions | Writes to `$GITHUB_OUTPUT` file |
|
|
12
|
+
| Azure DevOps | `##vso[task.setvariable]` commands |
|
|
13
|
+
| GitLab CI/CD | Writes to dotenv file |
|
|
14
|
+
| Other/Local | Logs `key=value` to stdout |
|
|
15
|
+
|
|
16
|
+
Platform detection is powered by [env-ci](https://github.com/semantic-release/env-ci).
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install --save-dev @at-blacknight/semantic-release-ci-output
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
Add the plugin to your `plugins` list in your semantic-release configuration. It replaces both `@semantic-release/changelog` and `@semantic-release/exec` for CI variable output.
|
|
27
|
+
|
|
28
|
+
```yaml
|
|
29
|
+
# .releaserc.yaml
|
|
30
|
+
plugins:
|
|
31
|
+
- "@semantic-release/commit-analyzer"
|
|
32
|
+
- "@semantic-release/release-notes-generator"
|
|
33
|
+
- "@at-blacknight/semantic-release-ci-output"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### With options
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
plugins:
|
|
40
|
+
- "@semantic-release/commit-analyzer"
|
|
41
|
+
- "@semantic-release/release-notes-generator"
|
|
42
|
+
- - "@at-blacknight/semantic-release-ci-output"
|
|
43
|
+
- writeNotesFile: true
|
|
44
|
+
notesFilePath: "CHANGELOG-NEW.md"
|
|
45
|
+
notesFileBehaviour: "overwrite"
|
|
46
|
+
setWouldRelease: true
|
|
47
|
+
setLastReleaseVariables: false
|
|
48
|
+
isOutput: true
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Output Variables
|
|
52
|
+
|
|
53
|
+
The following variables are set on the CI platform:
|
|
54
|
+
|
|
55
|
+
### Always set (analyzeCommits)
|
|
56
|
+
|
|
57
|
+
| Variable | Description |
|
|
58
|
+
|----------|-------------|
|
|
59
|
+
| `would_release` | `false` initially, set to `true` if a release is determined |
|
|
60
|
+
|
|
61
|
+
### Set when a release is determined (verifyRelease)
|
|
62
|
+
|
|
63
|
+
| Variable | Description |
|
|
64
|
+
|----------|-------------|
|
|
65
|
+
| `would_release` | `true` |
|
|
66
|
+
| `version` | The next release version (e.g. `1.2.3`) |
|
|
67
|
+
| `release_type` | The release type (`major`, `minor`, `patch`, `prerelease`) |
|
|
68
|
+
| `release_channel` | The release channel (e.g. `uat`, `develop`, or empty for default) |
|
|
69
|
+
| `git_tag` | The git tag for the release (e.g. `v1.2.3`) |
|
|
70
|
+
| `git_head` | The git commit SHA |
|
|
71
|
+
|
|
72
|
+
### Last release variables (opt-in)
|
|
73
|
+
|
|
74
|
+
When `setLastReleaseVariables: true`:
|
|
75
|
+
|
|
76
|
+
| Variable | Description |
|
|
77
|
+
|----------|-------------|
|
|
78
|
+
| `last_release_version` | Previous release version |
|
|
79
|
+
| `last_release_git_head` | Previous release commit SHA |
|
|
80
|
+
| `last_release_git_tag` | Previous release git tag |
|
|
81
|
+
| `last_release_channel` | Previous release channel |
|
|
82
|
+
|
|
83
|
+
## Changelog File
|
|
84
|
+
|
|
85
|
+
By default, the plugin writes the release notes to `CHANGELOG-NEW.md` in the working directory. This happens during `generateNotes` which runs even in `--dry-run` mode.
|
|
86
|
+
|
|
87
|
+
| Option | Default | Description |
|
|
88
|
+
|--------|---------|-------------|
|
|
89
|
+
| `writeNotesFile` | `true` | Write release notes to a file |
|
|
90
|
+
| `notesFilePath` | `CHANGELOG-NEW.md` | Path to the changelog file |
|
|
91
|
+
| `notesFileBehaviour` | `overwrite` | `overwrite`, `prepend`, or `append` |
|
|
92
|
+
|
|
93
|
+
## Configuration Options
|
|
94
|
+
|
|
95
|
+
| Option | Default | Description |
|
|
96
|
+
|--------|---------|-------------|
|
|
97
|
+
| `setWouldRelease` | `true` | Set the `would_release` variable |
|
|
98
|
+
| `setLastReleaseVariables` | `false` | Set variables for the last release |
|
|
99
|
+
| `isOutput` | `true` | Mark variables as output variables (ADO: `isOutput=true`) |
|
|
100
|
+
| `writeNotesFile` | `true` | Write changelog file |
|
|
101
|
+
| `notesFilePath` | `CHANGELOG-NEW.md` | Changelog file path |
|
|
102
|
+
| `notesFileBehaviour` | `overwrite` | How to handle existing changelog file |
|
|
103
|
+
|
|
104
|
+
## Lifecycle Hooks
|
|
105
|
+
|
|
106
|
+
| Hook | Purpose |
|
|
107
|
+
|------|---------|
|
|
108
|
+
| `analyzeCommits` | Sets `would_release=false` and last release variables |
|
|
109
|
+
| `verifyRelease` | Sets `would_release=true` and all release variables |
|
|
110
|
+
| `generateNotes` | Writes the changelog file |
|
|
111
|
+
|
|
112
|
+
## License
|
|
113
|
+
|
|
114
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const { getPlatform } = require('./platform')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* analyzeCommits hook — runs early in the lifecycle.
|
|
5
|
+
* Sets last release information and initialises would_release to false.
|
|
6
|
+
*/
|
|
7
|
+
module.exports = async (pluginConfig, context) => {
|
|
8
|
+
const { lastRelease, logger } = context
|
|
9
|
+
const { name, adapter } = getPlatform()
|
|
10
|
+
const isOutput = pluginConfig.isOutput !== false
|
|
11
|
+
|
|
12
|
+
logger.log(`Detected CI platform: ${name}`)
|
|
13
|
+
|
|
14
|
+
const opts = { logger, isOutput }
|
|
15
|
+
|
|
16
|
+
// Set would_release=false initially (overridden in verifyRelease if a release is determined)
|
|
17
|
+
if (pluginConfig.setWouldRelease !== false) {
|
|
18
|
+
adapter.setVariable('would_release', 'false', opts)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Set last release variables if available
|
|
22
|
+
if (pluginConfig.setLastReleaseVariables && lastRelease) {
|
|
23
|
+
if (lastRelease.version) {
|
|
24
|
+
adapter.setVariable('last_release_version', lastRelease.version, opts)
|
|
25
|
+
}
|
|
26
|
+
if (lastRelease.gitHead) {
|
|
27
|
+
adapter.setVariable('last_release_git_head', lastRelease.gitHead, opts)
|
|
28
|
+
}
|
|
29
|
+
if (lastRelease.gitTag) {
|
|
30
|
+
adapter.setVariable('last_release_git_tag', lastRelease.gitTag, opts)
|
|
31
|
+
}
|
|
32
|
+
if (lastRelease.channel) {
|
|
33
|
+
adapter.setVariable('last_release_channel', lastRelease.channel, opts)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
const path = require('path')
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* generateNotes hook — runs after release notes are generated.
|
|
6
|
+
* Writes the changelog to a file (works in dry-run mode, unlike @semantic-release/changelog).
|
|
7
|
+
*/
|
|
8
|
+
module.exports = async (pluginConfig, context) => {
|
|
9
|
+
const { nextRelease, logger } = context
|
|
10
|
+
const writeNotesFile = pluginConfig.writeNotesFile !== false
|
|
11
|
+
const notesFilePath = pluginConfig.notesFilePath || 'CHANGELOG-NEW.md'
|
|
12
|
+
const notesFileBehaviour = pluginConfig.notesFileBehaviour || 'overwrite'
|
|
13
|
+
|
|
14
|
+
if (!writeNotesFile) {
|
|
15
|
+
return nextRelease.notes
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (!nextRelease.notes) {
|
|
19
|
+
logger.log('No release notes available, skipping file write')
|
|
20
|
+
return nextRelease.notes
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const absolutePath = path.resolve(notesFilePath)
|
|
25
|
+
let finalContent = nextRelease.notes
|
|
26
|
+
|
|
27
|
+
if (fs.existsSync(absolutePath) && notesFileBehaviour !== 'overwrite') {
|
|
28
|
+
const existingContent = fs.readFileSync(absolutePath, 'utf8')
|
|
29
|
+
|
|
30
|
+
if (notesFileBehaviour === 'prepend') {
|
|
31
|
+
finalContent = nextRelease.notes + '\n\n' + existingContent
|
|
32
|
+
} else if (notesFileBehaviour === 'append') {
|
|
33
|
+
finalContent = existingContent + '\n\n' + nextRelease.notes
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fs.writeFileSync(absolutePath, finalContent, 'utf8')
|
|
38
|
+
logger.log(`Release notes written to ${notesFilePath} (${finalContent.length} bytes)`)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
logger.error(`Failed to write release notes: ${error.message}`)
|
|
41
|
+
throw error
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Return notes unchanged so other plugins can use them
|
|
45
|
+
return nextRelease.notes
|
|
46
|
+
}
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Platform adapters for writing CI output variables.
|
|
5
|
+
* Each adapter knows how to set a variable in its CI system.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const platforms = {
|
|
9
|
+
github: {
|
|
10
|
+
setVariable (name, value, { logger }) {
|
|
11
|
+
const outputFile = process.env.GITHUB_OUTPUT
|
|
12
|
+
if (!outputFile) {
|
|
13
|
+
logger.log(`GITHUB_OUTPUT not set, skipping variable ${name}`)
|
|
14
|
+
return
|
|
15
|
+
}
|
|
16
|
+
// Use delimiter for multiline values to avoid injection
|
|
17
|
+
const delimiter = `ghadelimiter_${Date.now()}`
|
|
18
|
+
fs.appendFileSync(outputFile, `${name}<<${delimiter}\n${value}\n${delimiter}\n`)
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
azurePipelines: {
|
|
23
|
+
setVariable (name, value, { isOutput }) {
|
|
24
|
+
const outputFlag = isOutput ? 'isOutput=true;' : ''
|
|
25
|
+
console.log(`##vso[task.setvariable variable=${name};${outputFlag}]${value}`)
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
gitlab: {
|
|
30
|
+
setVariable (name, value, { logger }) {
|
|
31
|
+
const dotenvFile = process.env.DOTENV_FILE || `${process.env.CI_PROJECT_DIR}/.env`
|
|
32
|
+
try {
|
|
33
|
+
fs.appendFileSync(dotenvFile, `${name}=${value}\n`)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// Fall back to stdout
|
|
36
|
+
logger.log('Could not write to dotenv file, falling back to stdout')
|
|
37
|
+
console.log(`${name}=${value}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Detect the current CI platform and return the appropriate adapter.
|
|
45
|
+
* Falls back to a console logger for unknown/local platforms.
|
|
46
|
+
*/
|
|
47
|
+
function getPlatform () {
|
|
48
|
+
const envCiModule = require('env-ci')
|
|
49
|
+
const envCi = envCiModule.default || envCiModule
|
|
50
|
+
const { service } = envCi()
|
|
51
|
+
|
|
52
|
+
if (platforms[service]) {
|
|
53
|
+
return { name: service, adapter: platforms[service] }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Fallback: log to stdout
|
|
57
|
+
return {
|
|
58
|
+
name: service || 'unknown',
|
|
59
|
+
adapter: {
|
|
60
|
+
setVariable (name, value) {
|
|
61
|
+
console.log(`${name}=${value}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { getPlatform, platforms }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { getPlatform } = require('./platform')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* verifyRelease hook — runs when a release has been determined.
|
|
5
|
+
* Sets would_release=true and outputs all release variables.
|
|
6
|
+
*/
|
|
7
|
+
module.exports = async (pluginConfig, context) => {
|
|
8
|
+
const { nextRelease, logger } = context
|
|
9
|
+
const { adapter } = getPlatform()
|
|
10
|
+
const isOutput = pluginConfig.isOutput !== false
|
|
11
|
+
|
|
12
|
+
const opts = { logger, isOutput }
|
|
13
|
+
|
|
14
|
+
logger.log(`Release determined: ${nextRelease.version} (${nextRelease.type})`)
|
|
15
|
+
|
|
16
|
+
// Override would_release to true
|
|
17
|
+
if (pluginConfig.setWouldRelease !== false) {
|
|
18
|
+
adapter.setVariable('would_release', 'true', opts)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Set release object variables
|
|
22
|
+
if (nextRelease.version) {
|
|
23
|
+
adapter.setVariable('version', nextRelease.version, opts)
|
|
24
|
+
}
|
|
25
|
+
if (nextRelease.type) {
|
|
26
|
+
adapter.setVariable('release_type', nextRelease.type, opts)
|
|
27
|
+
}
|
|
28
|
+
if (nextRelease.channel) {
|
|
29
|
+
adapter.setVariable('release_channel', nextRelease.channel, opts)
|
|
30
|
+
}
|
|
31
|
+
if (nextRelease.gitTag) {
|
|
32
|
+
adapter.setVariable('git_tag', nextRelease.gitTag, opts)
|
|
33
|
+
}
|
|
34
|
+
if (nextRelease.gitHead) {
|
|
35
|
+
adapter.setVariable('git_head', nextRelease.gitHead, opts)
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@at-blacknight/semantic-release-ci-output",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Semantic release plugin that outputs release information to CI platforms (GitHub Actions, Azure DevOps, GitLab CI, and more).",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test --test-concurrency=1",
|
|
8
|
+
"lint": "eslint lib index.js"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/at-blacknight-projects/semantic-release-ci-output.git"
|
|
13
|
+
},
|
|
14
|
+
"author": "Adam Butler",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/at-blacknight-projects/semantic-release-ci-output/issues"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/at-blacknight-projects/semantic-release-ci-output#readme",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"semantic-release",
|
|
22
|
+
"github-actions",
|
|
23
|
+
"azure-devops",
|
|
24
|
+
"gitlab-ci",
|
|
25
|
+
"ci",
|
|
26
|
+
"pipeline",
|
|
27
|
+
"output",
|
|
28
|
+
"variables"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"env-ci": "^11.0.0"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"semantic-release": ">=20.0.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
38
|
+
"@semantic-release/git": "^10.0.1",
|
|
39
|
+
"@semantic-release/github": "^12.0.6",
|
|
40
|
+
"eslint": "^8.57.1",
|
|
41
|
+
"eslint-config-standard": "^17.1.0",
|
|
42
|
+
"eslint-plugin-import": "^2.32.0",
|
|
43
|
+
"eslint-plugin-n": "^16.6.2",
|
|
44
|
+
"eslint-plugin-promise": "^6.6.0",
|
|
45
|
+
"semantic-release": "^25.0.3"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
describe('analyzeCommits', () => {
|
|
7
|
+
const tmpFile = path.join(__dirname, 'test-github-output.txt')
|
|
8
|
+
let analyzeCommits
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
fs.writeFileSync(tmpFile, '')
|
|
12
|
+
process.env.GITHUB_OUTPUT = tmpFile
|
|
13
|
+
process.env.GITHUB_ACTIONS = 'true'
|
|
14
|
+
process.env.CI = 'true'
|
|
15
|
+
|
|
16
|
+
// Clear require cache so env-ci re-detects platform
|
|
17
|
+
Object.keys(require.cache).forEach(key => {
|
|
18
|
+
if (key.includes('env-ci') || key.includes('platform') || key.includes('analyzeCommits')) {
|
|
19
|
+
delete require.cache[key]
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
analyzeCommits = require('../lib/analyzeCommits')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile)
|
|
27
|
+
delete process.env.GITHUB_OUTPUT
|
|
28
|
+
delete process.env.GITHUB_ACTIONS
|
|
29
|
+
delete process.env.CI
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
function readOutput () {
|
|
33
|
+
return fs.readFileSync(tmpFile, 'utf8')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it('sets would_release to false', async () => {
|
|
37
|
+
await analyzeCommits({}, { lastRelease: {}, logger: { log () {} } })
|
|
38
|
+
const output = readOutput()
|
|
39
|
+
assert.ok(output.includes('would_release'))
|
|
40
|
+
assert.ok(output.includes('false'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('respects setWouldRelease: false', async () => {
|
|
44
|
+
await analyzeCommits(
|
|
45
|
+
{ setWouldRelease: false },
|
|
46
|
+
{ lastRelease: {}, logger: { log () {} } }
|
|
47
|
+
)
|
|
48
|
+
const output = readOutput()
|
|
49
|
+
assert.ok(!output.includes('would_release'))
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('sets last release variables when enabled', async () => {
|
|
53
|
+
const lastRelease = {
|
|
54
|
+
version: '2.0.0',
|
|
55
|
+
gitHead: 'abc123',
|
|
56
|
+
gitTag: 'v2.0.0',
|
|
57
|
+
channel: 'main'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await analyzeCommits(
|
|
61
|
+
{ setLastReleaseVariables: true },
|
|
62
|
+
{ lastRelease, logger: { log () {} } }
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const output = readOutput()
|
|
66
|
+
assert.ok(output.includes('last_release_version'))
|
|
67
|
+
assert.ok(output.includes('2.0.0'))
|
|
68
|
+
assert.ok(output.includes('last_release_git_head'))
|
|
69
|
+
assert.ok(output.includes('abc123'))
|
|
70
|
+
assert.ok(output.includes('last_release_git_tag'))
|
|
71
|
+
assert.ok(output.includes('v2.0.0'))
|
|
72
|
+
assert.ok(output.includes('last_release_channel'))
|
|
73
|
+
assert.ok(output.includes('main'))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('does not set last release variables by default', async () => {
|
|
77
|
+
await analyzeCommits(
|
|
78
|
+
{},
|
|
79
|
+
{ lastRelease: { version: '1.0.0' }, logger: { log () {} } }
|
|
80
|
+
)
|
|
81
|
+
const output = readOutput()
|
|
82
|
+
assert.ok(!output.includes('last_release_version'))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('handles missing lastRelease properties gracefully', async () => {
|
|
86
|
+
await analyzeCommits(
|
|
87
|
+
{ setLastReleaseVariables: true },
|
|
88
|
+
{ lastRelease: { version: '1.0.0' }, logger: { log () {} } }
|
|
89
|
+
)
|
|
90
|
+
const output = readOutput()
|
|
91
|
+
assert.ok(output.includes('last_release_version'))
|
|
92
|
+
assert.ok(!output.includes('last_release_git_head'))
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
const generateNotes = require('../lib/generateNotes')
|
|
7
|
+
|
|
8
|
+
describe('generateNotes', () => {
|
|
9
|
+
const tmpDir = path.join(__dirname, 'tmp')
|
|
10
|
+
const testFile = path.join(tmpDir, 'CHANGELOG-NEW.md')
|
|
11
|
+
const logger = { log () {}, error () {} }
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('writes release notes to file', async () => {
|
|
22
|
+
const notes = '## 1.0.0\n\n* Initial release'
|
|
23
|
+
const result = await generateNotes(
|
|
24
|
+
{ notesFilePath: testFile },
|
|
25
|
+
{ nextRelease: { notes }, logger }
|
|
26
|
+
)
|
|
27
|
+
assert.equal(fs.readFileSync(testFile, 'utf8'), notes)
|
|
28
|
+
assert.equal(result, notes)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns notes unchanged', async () => {
|
|
32
|
+
const notes = '## 2.0.0\n\n* Breaking change'
|
|
33
|
+
const result = await generateNotes(
|
|
34
|
+
{ notesFilePath: testFile },
|
|
35
|
+
{ nextRelease: { notes }, logger }
|
|
36
|
+
)
|
|
37
|
+
assert.equal(result, notes)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('skips file write when writeNotesFile is false', async () => {
|
|
41
|
+
const notes = '## 1.0.0\n\n* Some notes'
|
|
42
|
+
await generateNotes(
|
|
43
|
+
{ writeNotesFile: false, notesFilePath: testFile },
|
|
44
|
+
{ nextRelease: { notes }, logger }
|
|
45
|
+
)
|
|
46
|
+
assert.ok(!fs.existsSync(testFile))
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('skips file write when notes are empty', async () => {
|
|
50
|
+
await generateNotes(
|
|
51
|
+
{ notesFilePath: testFile },
|
|
52
|
+
{ nextRelease: { notes: '' }, logger }
|
|
53
|
+
)
|
|
54
|
+
assert.ok(!fs.existsSync(testFile))
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('skips file write when notes are undefined', async () => {
|
|
58
|
+
await generateNotes(
|
|
59
|
+
{ notesFilePath: testFile },
|
|
60
|
+
{ nextRelease: {}, logger }
|
|
61
|
+
)
|
|
62
|
+
assert.ok(!fs.existsSync(testFile))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('overwrites existing file by default', async () => {
|
|
66
|
+
fs.writeFileSync(testFile, 'old content')
|
|
67
|
+
const notes = '## 2.0.0\n\n* New release'
|
|
68
|
+
await generateNotes(
|
|
69
|
+
{ notesFilePath: testFile },
|
|
70
|
+
{ nextRelease: { notes }, logger }
|
|
71
|
+
)
|
|
72
|
+
assert.equal(fs.readFileSync(testFile, 'utf8'), notes)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('prepends to existing file', async () => {
|
|
76
|
+
fs.writeFileSync(testFile, '## 1.0.0\n\n* Old release')
|
|
77
|
+
const notes = '## 2.0.0\n\n* New release'
|
|
78
|
+
await generateNotes(
|
|
79
|
+
{ notesFilePath: testFile, notesFileBehaviour: 'prepend' },
|
|
80
|
+
{ nextRelease: { notes }, logger }
|
|
81
|
+
)
|
|
82
|
+
const content = fs.readFileSync(testFile, 'utf8')
|
|
83
|
+
assert.ok(content.startsWith('## 2.0.0'))
|
|
84
|
+
assert.ok(content.includes('## 1.0.0'))
|
|
85
|
+
assert.ok(content.indexOf('2.0.0') < content.indexOf('1.0.0'))
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('appends to existing file', async () => {
|
|
89
|
+
fs.writeFileSync(testFile, '## 1.0.0\n\n* Old release')
|
|
90
|
+
const notes = '## 2.0.0\n\n* New release'
|
|
91
|
+
await generateNotes(
|
|
92
|
+
{ notesFilePath: testFile, notesFileBehaviour: 'append' },
|
|
93
|
+
{ nextRelease: { notes }, logger }
|
|
94
|
+
)
|
|
95
|
+
const content = fs.readFileSync(testFile, 'utf8')
|
|
96
|
+
assert.ok(content.startsWith('## 1.0.0'))
|
|
97
|
+
assert.ok(content.includes('## 2.0.0'))
|
|
98
|
+
assert.ok(content.indexOf('1.0.0') < content.indexOf('2.0.0'))
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('creates file if it does not exist for prepend mode', async () => {
|
|
102
|
+
const notes = '## 1.0.0\n\n* First release'
|
|
103
|
+
await generateNotes(
|
|
104
|
+
{ notesFilePath: testFile, notesFileBehaviour: 'prepend' },
|
|
105
|
+
{ nextRelease: { notes }, logger }
|
|
106
|
+
)
|
|
107
|
+
assert.equal(fs.readFileSync(testFile, 'utf8'), notes)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('uses default path CHANGELOG-NEW.md', async () => {
|
|
111
|
+
const defaultFile = path.resolve('CHANGELOG-NEW.md')
|
|
112
|
+
const notes = '## 1.0.0\n\n* Test'
|
|
113
|
+
try {
|
|
114
|
+
await generateNotes({}, { nextRelease: { notes }, logger })
|
|
115
|
+
assert.ok(fs.existsSync(defaultFile))
|
|
116
|
+
assert.equal(fs.readFileSync(defaultFile, 'utf8'), notes)
|
|
117
|
+
} finally {
|
|
118
|
+
if (fs.existsSync(defaultFile)) fs.unlinkSync(defaultFile)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const { platforms } = require('../lib/platform')
|
|
6
|
+
|
|
7
|
+
describe('platform adapters', () => {
|
|
8
|
+
describe('github', () => {
|
|
9
|
+
const tmpFile = path.join(__dirname, 'test-github-output.txt')
|
|
10
|
+
const logger = { log () {} }
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
fs.writeFileSync(tmpFile, '')
|
|
14
|
+
process.env.GITHUB_OUTPUT = tmpFile
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile)
|
|
19
|
+
delete process.env.GITHUB_OUTPUT
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('writes variable to GITHUB_OUTPUT file', () => {
|
|
23
|
+
platforms.github.setVariable('version', '1.2.3', { logger, isOutput: true })
|
|
24
|
+
const content = fs.readFileSync(tmpFile, 'utf8')
|
|
25
|
+
assert.match(content, /version<<ghadelimiter_\d+\n1\.2\.3\nghadelimiter_\d+\n/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('handles multiline values with delimiter', () => {
|
|
29
|
+
platforms.github.setVariable('notes', 'Line 1\nLine 2\nLine 3', { logger, isOutput: true })
|
|
30
|
+
const content = fs.readFileSync(tmpFile, 'utf8')
|
|
31
|
+
assert.ok(content.includes('Line 1\nLine 2\nLine 3'))
|
|
32
|
+
// Delimiter should wrap the value
|
|
33
|
+
const lines = content.split('\n')
|
|
34
|
+
assert.match(lines[0], /notes<<ghadelimiter_\d+/)
|
|
35
|
+
assert.match(lines[lines.length - 2], /ghadelimiter_\d+/)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('skips when GITHUB_OUTPUT is not set', () => {
|
|
39
|
+
delete process.env.GITHUB_OUTPUT
|
|
40
|
+
const logs = []
|
|
41
|
+
const testLogger = { log: (msg) => logs.push(msg) }
|
|
42
|
+
platforms.github.setVariable('version', '1.2.3', { logger: testLogger, isOutput: true })
|
|
43
|
+
assert.ok(logs.some(l => l.includes('GITHUB_OUTPUT not set')))
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('appends multiple variables to the same file', () => {
|
|
47
|
+
platforms.github.setVariable('version', '1.0.0', { logger, isOutput: true })
|
|
48
|
+
platforms.github.setVariable('release_type', 'major', { logger, isOutput: true })
|
|
49
|
+
const content = fs.readFileSync(tmpFile, 'utf8')
|
|
50
|
+
assert.ok(content.includes('version'))
|
|
51
|
+
assert.ok(content.includes('release_type'))
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('azurePipelines', () => {
|
|
56
|
+
it('outputs vso command with isOutput flag', () => {
|
|
57
|
+
const logs = []
|
|
58
|
+
const origLog = console.log
|
|
59
|
+
console.log = (msg) => logs.push(msg)
|
|
60
|
+
try {
|
|
61
|
+
platforms.azurePipelines.setVariable('version', '1.2.3', { isOutput: true })
|
|
62
|
+
assert.equal(logs[0], '##vso[task.setvariable variable=version;isOutput=true;]1.2.3')
|
|
63
|
+
} finally {
|
|
64
|
+
console.log = origLog
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('outputs vso command without isOutput flag', () => {
|
|
69
|
+
const logs = []
|
|
70
|
+
const origLog = console.log
|
|
71
|
+
console.log = (msg) => logs.push(msg)
|
|
72
|
+
try {
|
|
73
|
+
platforms.azurePipelines.setVariable('version', '1.2.3', { isOutput: false })
|
|
74
|
+
assert.equal(logs[0], '##vso[task.setvariable variable=version;]1.2.3')
|
|
75
|
+
} finally {
|
|
76
|
+
console.log = origLog
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe('gitlab', () => {
|
|
82
|
+
const tmpFile = path.join(__dirname, 'test-dotenv.txt')
|
|
83
|
+
const logger = { log () {} }
|
|
84
|
+
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
process.env.DOTENV_FILE = tmpFile
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile)
|
|
91
|
+
delete process.env.DOTENV_FILE
|
|
92
|
+
delete process.env.CI_PROJECT_DIR
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('writes variable to dotenv file', () => {
|
|
96
|
+
fs.writeFileSync(tmpFile, '')
|
|
97
|
+
platforms.gitlab.setVariable('version', '1.2.3', { logger, isOutput: true })
|
|
98
|
+
const content = fs.readFileSync(tmpFile, 'utf8')
|
|
99
|
+
assert.equal(content, 'version=1.2.3\n')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('falls back to stdout when dotenv file is not writable', () => {
|
|
103
|
+
delete process.env.DOTENV_FILE
|
|
104
|
+
process.env.CI_PROJECT_DIR = '/nonexistent/path'
|
|
105
|
+
const logs = []
|
|
106
|
+
const origLog = console.log
|
|
107
|
+
console.log = (msg) => logs.push(msg)
|
|
108
|
+
const loggerLogs = []
|
|
109
|
+
const testLogger = { log: (msg) => loggerLogs.push(msg) }
|
|
110
|
+
try {
|
|
111
|
+
platforms.gitlab.setVariable('version', '1.2.3', { logger: testLogger, isOutput: true })
|
|
112
|
+
assert.ok(loggerLogs.some(l => l.includes('falling back to stdout')))
|
|
113
|
+
assert.ok(logs.some(l => l === 'version=1.2.3'))
|
|
114
|
+
} finally {
|
|
115
|
+
console.log = origLog
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const { describe, it, beforeEach, afterEach } = require('node:test')
|
|
2
|
+
const assert = require('node:assert/strict')
|
|
3
|
+
const fs = require('fs')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
|
|
6
|
+
describe('verifyRelease', () => {
|
|
7
|
+
const tmpFile = path.join(__dirname, 'test-github-output.txt')
|
|
8
|
+
let verifyRelease
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
fs.writeFileSync(tmpFile, '')
|
|
12
|
+
process.env.GITHUB_OUTPUT = tmpFile
|
|
13
|
+
process.env.GITHUB_ACTIONS = 'true'
|
|
14
|
+
process.env.CI = 'true'
|
|
15
|
+
|
|
16
|
+
Object.keys(require.cache).forEach(key => {
|
|
17
|
+
if (key.includes('env-ci') || key.includes('platform') || key.includes('verifyRelease')) {
|
|
18
|
+
delete require.cache[key]
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
verifyRelease = require('../lib/verifyRelease')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
if (fs.existsSync(tmpFile)) fs.unlinkSync(tmpFile)
|
|
26
|
+
delete process.env.GITHUB_OUTPUT
|
|
27
|
+
delete process.env.GITHUB_ACTIONS
|
|
28
|
+
delete process.env.CI
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function readOutput () {
|
|
32
|
+
return fs.readFileSync(tmpFile, 'utf8')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
it('sets would_release to true', async () => {
|
|
36
|
+
const nextRelease = { version: '1.0.0', type: 'major', gitTag: 'v1.0.0', gitHead: 'abc123' }
|
|
37
|
+
await verifyRelease({}, { nextRelease, logger: { log () {} } })
|
|
38
|
+
const output = readOutput()
|
|
39
|
+
assert.ok(output.includes('would_release'))
|
|
40
|
+
assert.ok(output.includes('true'))
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('sets all release variables', async () => {
|
|
44
|
+
const nextRelease = {
|
|
45
|
+
version: '2.1.0',
|
|
46
|
+
type: 'minor',
|
|
47
|
+
channel: 'beta',
|
|
48
|
+
gitTag: 'v2.1.0-beta.1',
|
|
49
|
+
gitHead: 'def456'
|
|
50
|
+
}
|
|
51
|
+
await verifyRelease({}, { nextRelease, logger: { log () {} } })
|
|
52
|
+
const output = readOutput()
|
|
53
|
+
assert.ok(output.includes('version'))
|
|
54
|
+
assert.ok(output.includes('2.1.0'))
|
|
55
|
+
assert.ok(output.includes('release_type'))
|
|
56
|
+
assert.ok(output.includes('minor'))
|
|
57
|
+
assert.ok(output.includes('release_channel'))
|
|
58
|
+
assert.ok(output.includes('beta'))
|
|
59
|
+
assert.ok(output.includes('git_tag'))
|
|
60
|
+
assert.ok(output.includes('v2.1.0-beta.1'))
|
|
61
|
+
assert.ok(output.includes('git_head'))
|
|
62
|
+
assert.ok(output.includes('def456'))
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('respects setWouldRelease: false', async () => {
|
|
66
|
+
const nextRelease = { version: '1.0.0', type: 'major', gitTag: 'v1.0.0', gitHead: 'abc123' }
|
|
67
|
+
await verifyRelease({ setWouldRelease: false }, { nextRelease, logger: { log () {} } })
|
|
68
|
+
const output = readOutput()
|
|
69
|
+
assert.ok(!output.includes('would_release'))
|
|
70
|
+
// Other variables should still be set
|
|
71
|
+
assert.ok(output.includes('version'))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('handles missing optional nextRelease properties', async () => {
|
|
75
|
+
const nextRelease = { version: '1.0.0', type: 'patch' }
|
|
76
|
+
await verifyRelease({}, { nextRelease, logger: { log () {} } })
|
|
77
|
+
const output = readOutput()
|
|
78
|
+
assert.ok(output.includes('version'))
|
|
79
|
+
assert.ok(output.includes('release_type'))
|
|
80
|
+
assert.ok(!output.includes('release_channel'))
|
|
81
|
+
assert.ok(!output.includes('git_tag'))
|
|
82
|
+
assert.ok(!output.includes('git_head'))
|
|
83
|
+
})
|
|
84
|
+
})
|