- add new experimental OFFLINE mode which will only generate changelogs based on commits

- FIX https://github.com/mikepenz/release-changelog-builder-action/issues/1459
This commit is contained in:
Mike Penz
2025-07-13 15:05:13 +02:00
parent 9d5249982e
commit 86dff767a7
11 changed files with 336 additions and 3 deletions
+1
View File
@@ -227,6 +227,7 @@ Depending on the use-case additional settings can be provided to the action
| `fetchReleaseInformation` | Will enable fetching additional release information from tags. Default: false | | `fetchReleaseInformation` | Will enable fetching additional release information from tags. Default: false |
| `fetchReviews` | Will enable fetching the reviews on of the PR. Default: false | | `fetchReviews` | Will enable fetching the reviews on of the PR. Default: false |
| `mode` | Defines the mode used to retrieve the information. Available options: [`PR`, `COMMIT`, `HYBRID`]. Defaults to `PR`. Hybrid mode treats commits like pull requests. Commit mode is a special configuration for projects which work without PRs. Uses commit messages as changelog. This mode looses access to information only available for PRs. Formerly set as `commitMode: true`, this setting is now deprecated and should be converted to `mode: "COMMIT"`. Note: the commit or hybrid modes are not fully supported. | | `mode` | Defines the mode used to retrieve the information. Available options: [`PR`, `COMMIT`, `HYBRID`]. Defaults to `PR`. Hybrid mode treats commits like pull requests. Commit mode is a special configuration for projects which work without PRs. Uses commit messages as changelog. This mode looses access to information only available for PRs. Formerly set as `commitMode: true`, this setting is now deprecated and should be converted to `mode: "COMMIT"`. Note: the commit or hybrid modes are not fully supported. |
| `offlineMode` | [EXPERIMENTAL] Enables offline mode which disables API requests to GitHub or Gitea. Only works with commitMode and retrieves tags and diffs from the local repository. Default: false |
| `exportCache` | Will enable exporting the fetched PR information to a cache, which can be re-used by later runs. Default: false | | `exportCache` | Will enable exporting the fetched PR information to a cache, which can be re-used by later runs. Default: false |
| `exportOnly` | When enabled, will result in only exporting the cache, without generating a changelog. Default: false (Requires `exportCache` to be enabled) | | `exportOnly` | When enabled, will result in only exporting the cache, without generating a changelog. Default: false (Requires `exportCache` to be enabled) |
| `cache` | The file path to write/read the cache to/from. | | `cache` | The file path to write/read the cache to/from. |
+34
View File
@@ -5,6 +5,9 @@ import * as fs from 'fs'
import {clear} from '../src/transform.js' import {clear} from '../src/transform.js'
import {jest} from '@jest/globals' import {jest} from '@jest/globals'
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import {mergeConfiguration, resolveConfiguration} from '../src/utils.js'
import {ReleaseNotesBuilder} from '../src/releaseNotesBuilder.js'
import {OfflineRepository} from '../src/repositories/OfflineRepository.js'
jest.setTimeout(180000) jest.setTimeout(180000)
clear() clear()
@@ -71,3 +74,34 @@ test('should write result to file', () => {
expect(readOutput.toString()).not.toBe('') expect(readOutput.toString()).not.toBe('')
}) })
test('offline mode should work with commit mode', () => {
// This test verifies that the offlineMode parameter is correctly passed to the configuration
// and that the OfflineRepository is used when offlineMode is enabled.
// Set up environment variables for the test
process.env['GITHUB_WORKSPACE'] = '.'
process.env['INPUT_CONFIGURATION'] = 'configuration.json'
process.env['INPUT_OWNER'] = 'mikepenz'
process.env['INPUT_REPO'] = 'release-changelog-builder-action'
process.env['INPUT_MODE'] = 'PR'
process.env['INPUT_OFFLINEMODE'] = 'true'
process.env['INPUT_OUTPUTFILE'] = 'test.md'
const ip = path.join(__dirname, '..', 'lib', 'main.js')
const options: cp.ExecSyncOptions = {
env: process.env
}
const result = cp.execSync(`node ${ip}`, options).toString()
// should succeed
expect(result).toBeDefined()
const readOutput = fs.readFileSync('test.md')
fs.unlinkSync('test.md')
expect(readOutput.toString()).not.toBe("- no changes")
expect(readOutput.toString()).not.toBe('')
console.log('Offline mode test succeeded')
})
+67
View File
@@ -0,0 +1,67 @@
/**
* @file offlineMode.test.ts
* @description Test file for validating the behavior of the new offline mode feature.
*
* This test verifies that:
* 1. The offlineMode parameter is correctly passed to the configuration
* 2. The OfflineRepository is used when offlineMode is enabled
* 3. Local tag and diff retrieval works correctly
*
* To run this test:
* npm run test-offline
*
* Note: This test requires a local git repository with tags to work properly.
*/
import {mergeConfiguration, resolveConfiguration} from '../../src/utils.js'
import {ReleaseNotesBuilder} from '../../src/releaseNotesBuilder.js'
import {OfflineRepository} from '../../src/repositories/OfflineRepository.js'
import {jest} from '@jest/globals'
jest.setTimeout(180000)
// This test validates the behavior of the new offline mode
test('Test offline mode functionality', async () => {
// Define the configuration file to use
const configuration = mergeConfiguration(undefined, resolveConfiguration('', 'configs/configuration_commit.json'))
// Set offlineMode to true in the configuration
configuration.offlineMode = true
// Create an instance of OfflineRepository
const offlineRepository = new OfflineRepository( '.')
const releaseNotesBuilder = new ReleaseNotesBuilder(
null, // The base url used for the API requests (not needed for offline mode)
offlineRepository, // Use the OfflineRepository implementation
'.', // Root path to the checked out sources
'mikepenz', // The owner of the repo to test
'release-changelog-builder-action', // The repository name - using this repo itself for the test
null, // fromTag - will be resolved automatically
null, // toTag - will be resolved automatically
false, // includeOpen - not supported in offline mode
false, // failOnError
false, // ignorePrePrerelease
false, // fetchViaCommits - not needed in offline mode
false, // fetchReviewers - not supported in offline mode
false, // fetchReleaseInformation
false, // fetchReviews - not supported in offline mode
'COMMIT', // mode - must be COMMIT for offline mode
false, // exportCache
false, // exportOnly
null, // cache
configuration // The configuration to use
)
// Build the changelog
const changeLog = await releaseNotesBuilder.build()
// Verify that a changelog was generated
expect(changeLog).toBeDefined()
expect(changeLog).not.toBe("- no changes")
expect(changeLog?.length).toBeGreaterThan(0)
// Log the changelog for inspection
console.log('Generated changelog in offline mode:')
console.log(changeLog)
})
+3
View File
@@ -45,6 +45,9 @@ inputs:
commitMode: commitMode:
description: '[Deprecated] Enables the commit based mode. This mode generates changelogs based on the commits. Please note that this lacks a lot of features only possible with PRs.' description: '[Deprecated] Enables the commit based mode. This mode generates changelogs based on the commits. Please note that this lacks a lot of features only possible with PRs.'
default: "false" default: "false"
offlineMode:
description: 'Enables offline mode which disables API requests to GitHub or Gitea. Only works with commitMode and retrieves tags and diffs from the local repository.'
default: "false"
outputFile: outputFile:
description: 'If defined, the changelog will get written to this file. (relative to the checkout dir)' description: 'If defined, the changelog will get written to this file. (relative to the checkout dir)'
token: token:
+26
View File
@@ -0,0 +1,26 @@
{
"categories": [
{
"title": "## 🚀 Features",
"labels": ["feature"]
},
{
"title": "## 🐛 Fixes",
"labels": ["fix"]
},
{
"title": "## 🧪 Tests",
"labels": ["test"]
},
{
"title": "## Other",
"labels": []
}
],
"sort": "ASC",
"template": "${{CHANGELOG}}",
"pr_template": "- ${{TITLE}}",
"empty_template": "- no changes",
"max_pull_requests": 1000,
"max_back_track_time_days": 1000
}
+1
View File
@@ -16,6 +16,7 @@
"test-github": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/*.test.ts", "test-github": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/*.test.ts",
"test-gitea": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/gitea/*.test.ts", "test-gitea": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/gitea/*.test.ts",
"test-demo": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/demo/*.test.ts", "test-demo": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/demo/*.test.ts",
"test-offline": "NODE_OPTIONS=--experimental-vm-modules jest __tests__/offline/*.test.ts",
"all": "npm run build && npm run format && npm run lint && npm run package && npm run test-github" "all": "npm run build && npm run format && npm run lint && npm run package && npm run test-github"
}, },
"repository": { "repository": {
+2 -1
View File
@@ -104,7 +104,8 @@ export const DefaultConfiguration: Configuration = {
}, },
base_branches: [], // target branches for the merged PR ignoring PRs with different target branch, by default it will get all PRs base_branches: [], // target branches for the merged PR ignoring PRs with different target branch, by default it will get all PRs
custom_placeholders: [], custom_placeholders: [],
trim_values: false // defines if values are being trimmed prior to inserting trim_values: false, // defines if values are being trimmed prior to inserting
offlineMode: false // defines if the action should run in offline mode, disabling API requests
} }
export const DefaultCommitConfiguration: Configuration = { export const DefaultCommitConfiguration: Configuration = {
+14 -2
View File
@@ -5,6 +5,7 @@ import {ReleaseNotesBuilder} from './releaseNotesBuilder.js'
import {Configuration} from './configuration.js' import {Configuration} from './configuration.js'
import {GithubRepository} from './repositories/GithubRepository.js' import {GithubRepository} from './repositories/GithubRepository.js'
import {GiteaRepository} from './repositories/GiteaRepository.js' import {GiteaRepository} from './repositories/GiteaRepository.js'
import {OfflineRepository} from './repositories/OfflineRepository.js'
async function run(): Promise<void> { async function run(): Promise<void> {
const supportedPlatform = { const supportedPlatform = {
@@ -52,7 +53,15 @@ async function run(): Promise<void> {
} }
// mode of the action (PR, COMMIT, HYBRID) // mode of the action (PR, COMMIT, HYBRID)
const mode = resolveMode(core.getInput('mode'), core.getInput('commitMode') === 'true') let mode = resolveMode(core.getInput('mode'), core.getInput('commitMode') === 'true')
const offlineMode = core.getInput('offlineMode') === 'true'
// If offline mode is enabled, ensure commit mode is used
if (offlineMode && mode !== 'COMMIT') {
core.warning('⚠️ Offline mode requires commit mode. Switching to commit mode.')
mode = 'COMMIT'
}
core.info(`️ Running in ${mode} mode.`) core.info(`️ Running in ${mode} mode.`)
// merge configs, use default values from DefaultConfig on missing definition // merge configs, use default values from DefaultConfig on missing definition
@@ -78,7 +87,10 @@ async function run(): Promise<void> {
const exportOnly = core.getInput('exportOnly') === 'true' const exportOnly = core.getInput('exportOnly') === 'true'
const cache = core.getInput('cache') const cache = core.getInput('cache')
const repositoryUtils = new supportedPlatform[platform](token, baseUrl, repositoryPath) // Use OfflineRepository if offline mode is enabled, otherwise use the selected platform
const repositoryUtils = offlineMode
? new OfflineRepository(repositoryPath)
: new supportedPlatform[platform](token, baseUrl, repositoryPath)
const result = await new ReleaseNotesBuilder( const result = await new ReleaseNotesBuilder(
baseUrl, baseUrl,
repositoryUtils, repositoryUtils,
+74
View File
@@ -33,6 +33,80 @@ class GitCommandManager {
return creationDate.stdout.trim().replace(/"/g, '') return creationDate.stdout.trim().replace(/"/g, '')
} }
async getAllTags(): Promise<string[]> {
const tagsOutput = await this.execGit(['tag', '-l'])
return tagsOutput.stdout.trim().split('\n').filter(tag => tag.trim() !== '')
}
async getTagCommit(tagName: string): Promise<string> {
const commitOutput = await this.execGit(['rev-list', '-n', '1', tagName])
return commitOutput.stdout.trim()
}
async getDiffStats(base: string, head: string): Promise<{
changedFiles: number;
additions: number;
deletions: number;
changes: number;
}> {
const diffOutput = await this.execGit(['diff', '--numstat', `${base}..${head}`])
const lines = diffOutput.stdout.trim().split('\n').filter(line => line.trim() !== '')
let additions = 0
let deletions = 0
for (const line of lines) {
const parts = line.split('\t')
if (parts.length >= 2) {
additions += parseInt(parts[0], 10) || 0
deletions += parseInt(parts[1], 10) || 0
}
}
return {
changedFiles: lines.length,
additions,
deletions,
changes: additions + deletions
}
}
async getCommitsBetween(base: string, head: string): Promise<{
count: number;
commits: {
sha: string;
subject: string
message: string;
author: string;
authorName: string;
authorDate: string;
}[];
}> {
const logOutput = await this.execGit([
'log',
'--pretty=format:%H|%an|%ae|%aI|%s|%b',
`${base}..${head}`
])
const lines = logOutput.stdout.trim().split('\n').filter(line => line.trim() !== '')
const commits = lines.map(line => {
const [sha, authorName, authorEmail, authorDate, subject, body] = line.split('|')
return {
sha,
subject,
message: body,
author: authorEmail,
authorName,
authorDate
}
})
return {
count: commits.length,
commits
}
}
static async createCommandManager(workingDirectory: string): Promise<GitCommandManager> { static async createCommandManager(workingDirectory: string): Promise<GitCommandManager> {
const result = new GitCommandManager() const result = new GitCommandManager()
await result.initializeCommandManager(workingDirectory) await result.initializeCommandManager(workingDirectory)
+1
View File
@@ -6,6 +6,7 @@ export interface PullConfiguration {
sort: Sort | string // "ASC" or "DESC" sort: Sort | string // "ASC" or "DESC"
tag_resolver: TagResolver tag_resolver: TagResolver
base_branches: string[] base_branches: string[]
offlineMode?: boolean
} }
/** /**
+113
View File
@@ -0,0 +1,113 @@
import * as core from "@actions/core";
import moment from "moment";
import { BaseRepository } from "./BaseRepository.js";
import { TagInfo } from "../pr-collector/tags.js";
import { DiffInfo } from "../pr-collector/commits.js";
import { PullRequestInfo } from "../pr-collector/pullRequests.js";
import { createCommandManager } from "../pr-collector/gitHelper.js";
export class OfflineRepository extends BaseRepository {
constructor(repositoryPath: string) {
super("", "offline", repositoryPath);
this.url = this.defaultUrl;
}
get defaultUrl(): string {
return "offline";
}
get homeUrl(): string {
return "offline";
}
async getTags(owner: string, repo: string, maxTagsToFetch: number): Promise<TagInfo[]> {
core.info(`️ Retrieving tags from local repository in offline mode`);
const gitHelper = await createCommandManager(this.repositoryPath);
const tags = await gitHelper.getAllTags();
// Limit the number of tags to maxTagsToFetch
const limitedTags = tags.slice(0, maxTagsToFetch);
// Convert to TagInfo objects
const tagInfos: TagInfo[] = [];
for (const tag of limitedTags) {
const commit = await gitHelper.getTagCommit(tag);
tagInfos.push({
name: tag,
commit
});
}
core.info(`️ Retrieved ${tagInfos.length} tags from local repository`);
return tagInfos;
}
async fillTagInformation(repositoryPath: string, owner: string, repo: string, tagInfo: TagInfo): Promise<TagInfo> {
return this.getTagByCreateTime(repositoryPath, tagInfo);
}
async getDiffRemote(owner: string, repo: string, base: string, head: string): Promise<DiffInfo> {
core.info(`️ Getting diff information from local repository in offline mode`);
const gitHelper = await createCommandManager(this.repositoryPath);
// Get diff stats
const diffStats = await gitHelper.getDiffStats(base, head);
// Get commits
const commitInfo = await gitHelper.getCommitsBetween(base, head);
return {
changedFiles: diffStats.changedFiles,
additions: diffStats.additions,
deletions: diffStats.deletions,
changes: diffStats.changes,
commits: commitInfo.count,
commitInfo: commitInfo.commits.map(commit => ({
sha: commit.sha,
summary: commit.subject.split('\n')[0],
message: commit.message,
author: commit.author,
authorName: commit.authorName,
authorDate: moment(commit.authorDate),
committer: "",
committerName: "",
commitDate: moment(commit.authorDate),
prNumber: undefined
}))
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getForCommitHash(owner: string, repo: string, commit_sha: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
core.info(`⚠️ getForCommitHash not supported in offline mode`);
return [];
}
async getBetweenDates(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
owner: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
repo: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fromDate: moment.Moment,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
toDate: moment.Moment,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
maxPullRequests: number
): Promise<PullRequestInfo[]> {
core.info(`⚠️ getBetweenDates not supported in offline mode`);
return [];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getOpen(owner: string, repo: string, maxPullRequests: number): Promise<PullRequestInfo[]> {
core.info(`⚠️ getOpen not supported in offline mode`);
return [];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async getReviews(owner: string, repo: string, pr: PullRequestInfo): Promise<void> {
core.info(`⚠️ getReviews not supported in offline mode`);
}
}