@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 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,9 @@
1
+ const analyzeCommits = require('./lib/analyzeCommits')
2
+ const verifyRelease = require('./lib/verifyRelease')
3
+ const generateNotes = require('./lib/generateNotes')
4
+
5
+ module.exports = {
6
+ analyzeCommits,
7
+ verifyRelease,
8
+ generateNotes
9
+ }
@@ -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
+ }
@@ -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
+ })