@wpelevator/wp-release 0.4.0 → 0.5.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/dist/errors.d.ts CHANGED
@@ -1,9 +1,27 @@
1
+ /**
2
+ * One failed check, with the file and line it's about when there is one.
3
+ */
4
+ export interface Problem {
5
+ /** Description of the problem. */
6
+ message: string;
7
+ /** File the problem is in, absolute or relative to the working directory. */
8
+ file?: string | null;
9
+ /** Line in the file, starting at 1. */
10
+ line?: number | null;
11
+ /** Short title of the kind of problem, like "Version mismatch". */
12
+ title?: string;
13
+ }
1
14
  /**
2
15
  * Invalid options or failed release checks, reported with exit code 2.
16
+ * Failed checks can list their problems, so that each one is reported on its
17
+ * own file and line.
3
18
  */
4
19
  export declare class ValidationError extends Error {
5
20
  name: string;
6
21
  exitCode: number;
22
+ /** Each failed check, empty when the message is all there is. */
23
+ readonly problems: Problem[];
24
+ constructor(message: string, problems?: Problem[]);
7
25
  }
8
26
  /**
9
27
  * Missing SVN credentials or a failed login or permission check, reported
package/dist/errors.js CHANGED
@@ -1,9 +1,17 @@
1
1
  /**
2
2
  * Invalid options or failed release checks, reported with exit code 2.
3
+ * Failed checks can list their problems, so that each one is reported on its
4
+ * own file and line.
3
5
  */
4
6
  export class ValidationError extends Error {
5
7
  name = 'ValidationError';
6
8
  exitCode = 2;
9
+ /** Each failed check, empty when the message is all there is. */
10
+ problems;
11
+ constructor(message, problems = []) {
12
+ super(message);
13
+ this.problems = problems;
14
+ }
7
15
  }
8
16
  /**
9
17
  * Missing SVN credentials or a failed login or permission check, reported
@@ -0,0 +1,52 @@
1
+ import type { Annotation, LogEntry, LogWriter, OutputStream, SummaryBuilder } from './logger.ts';
2
+ export interface GitHubActionsWriterOptions {
3
+ /** Stream for regular output. */
4
+ stdout?: OutputStream;
5
+ /** Stream for warnings and errors. */
6
+ stderr?: OutputStream;
7
+ /**
8
+ * Directory that annotation and summary paths are relative to, defaults
9
+ * to GITHUB_WORKSPACE or the working directory.
10
+ */
11
+ workspace?: string;
12
+ /**
13
+ * File that summaries are appended to, defaults to GITHUB_STEP_SUMMARY.
14
+ * Null turns summaries off.
15
+ */
16
+ summaryPath?: string | null;
17
+ }
18
+ /**
19
+ * Writer that prints GitHub Actions workflow commands, so that notices,
20
+ * warnings and errors are annotations on the run and on the files they're
21
+ * about, and appends summaries to the job summary.
22
+ */
23
+ export declare class GitHubActionsWriter implements LogWriter {
24
+ readonly stdout: OutputStream;
25
+ readonly stderr: OutputStream;
26
+ readonly workspace: string;
27
+ readonly summaryPath: string | null;
28
+ constructor({ stdout, stderr, workspace, summaryPath, }?: GitHubActionsWriterOptions);
29
+ write({ level, message, annotation }: LogEntry): void;
30
+ startGroup(title: string): void;
31
+ endGroup(): void;
32
+ summary(build: SummaryBuilder): void;
33
+ /**
34
+ * Format a workflow command like
35
+ * "::error file=my-plugin.php,line=3,title=Version mismatch::message".
36
+ *
37
+ * @param name Command name.
38
+ * @param message Message.
39
+ * @param annotation File, line and title of the annotation.
40
+ * @return Workflow command with a line break.
41
+ */
42
+ formatCommand(name: string, message: string, annotation?: Annotation): string;
43
+ /**
44
+ * Get the path of an annotation file relative to the workspace. GitHub
45
+ * only links files of the repository, so files outside the workspace,
46
+ * like an extracted ZIP in a temporary directory, get no file.
47
+ *
48
+ * @param file File path, absolute or relative to the working directory.
49
+ * @return Path with forward slashes, or null when it's outside the workspace.
50
+ */
51
+ getAnnotationFile(file?: string | null): string | null;
52
+ }
@@ -0,0 +1,94 @@
1
+ import { appendFileSync } from 'node:fs';
2
+ import { getWorkspacePath } from "./summary.js";
3
+ /**
4
+ * Escape the message of a workflow command, so that a multi-line message
5
+ * stays one annotation.
6
+ *
7
+ * @param text Message.
8
+ * @return Escaped message.
9
+ */
10
+ function escapeData(text) {
11
+ return text
12
+ .replaceAll('%', '%25')
13
+ .replaceAll('\r', '%0D')
14
+ .replaceAll('\n', '%0A');
15
+ }
16
+ /**
17
+ * Escape a workflow command property like a file path or title.
18
+ *
19
+ * @param text Property value.
20
+ * @return Escaped value.
21
+ */
22
+ function escapeProperty(text) {
23
+ return escapeData(text).replaceAll(':', '%3A').replaceAll(',', '%2C');
24
+ }
25
+ /**
26
+ * Writer that prints GitHub Actions workflow commands, so that notices,
27
+ * warnings and errors are annotations on the run and on the files they're
28
+ * about, and appends summaries to the job summary.
29
+ */
30
+ export class GitHubActionsWriter {
31
+ stdout;
32
+ stderr;
33
+ workspace;
34
+ summaryPath;
35
+ constructor({ stdout = process.stdout, stderr = process.stderr, workspace = process.env.GITHUB_WORKSPACE || process.cwd(), summaryPath = process.env.GITHUB_STEP_SUMMARY || null, } = {}) {
36
+ this.stdout = stdout;
37
+ this.stderr = stderr;
38
+ this.workspace = workspace;
39
+ this.summaryPath = summaryPath;
40
+ }
41
+ write({ level, message, annotation }) {
42
+ const stream = 'warning' === level || 'error' === level
43
+ ? this.stderr
44
+ : this.stdout;
45
+ stream.write('info' === level
46
+ ? `${message}\n`
47
+ : this.formatCommand(level, message, annotation));
48
+ }
49
+ startGroup(title) {
50
+ this.stdout.write(`::group::${escapeData(title)}\n`);
51
+ }
52
+ endGroup() {
53
+ this.stdout.write('::endgroup::\n');
54
+ }
55
+ summary(build) {
56
+ if (this.summaryPath) {
57
+ // A blank line keeps the Markdown of separate summaries apart.
58
+ appendFileSync(this.summaryPath, `${build({ workspace: this.workspace }).trimEnd()}\n\n`);
59
+ }
60
+ }
61
+ /**
62
+ * Format a workflow command like
63
+ * "::error file=my-plugin.php,line=3,title=Version mismatch::message".
64
+ *
65
+ * @param name Command name.
66
+ * @param message Message.
67
+ * @param annotation File, line and title of the annotation.
68
+ * @return Workflow command with a line break.
69
+ */
70
+ formatCommand(name, message, annotation = {}) {
71
+ const file = this.getAnnotationFile(annotation.file);
72
+ const properties = [
73
+ file ? `file=${escapeProperty(file)}` : '',
74
+ file && annotation.line ? `line=${annotation.line}` : '',
75
+ annotation.title
76
+ ? `title=${escapeProperty(annotation.title)}`
77
+ : '',
78
+ ]
79
+ .filter(Boolean)
80
+ .join(',');
81
+ return `::${name}${properties ? ` ${properties}` : ''}::${escapeData(message)}\n`;
82
+ }
83
+ /**
84
+ * Get the path of an annotation file relative to the workspace. GitHub
85
+ * only links files of the repository, so files outside the workspace,
86
+ * like an extracted ZIP in a temporary directory, get no file.
87
+ *
88
+ * @param file File path, absolute or relative to the working directory.
89
+ * @return Path with forward slashes, or null when it's outside the workspace.
90
+ */
91
+ getAnnotationFile(file) {
92
+ return file ? getWorkspacePath(this.workspace, file) : null;
93
+ }
94
+ }
package/dist/index.d.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  export { run, execute, commands, getHelp, getCommandHelp } from './cli.ts';
2
+ export { logError, logProblems } from './report.ts';
2
3
  export { getDeployInputFromArgs } from './commands/deploy-svn.ts';
3
4
  export { ValidationError, CredentialsError } from './errors.ts';
4
- export { Logger } from './logger.ts';
5
+ export { Logger, TextWriter } from './logger.ts';
6
+ export { GitHubActionsWriter } from './github-actions-writer.ts';
5
7
  export { SVN_URL, isPrerelease, normalizeTag, isTagRef, isValidSlug, isValidVersion, getDeployOptionErrors, getDeployOptionWarnings, resolveDeployOptions, } from './deploy-options.ts';
6
8
  export { DISTIGNORE_FILENAME, parseExclude, resolveFileSelection, loadFileRules, selectFiles, } from './file-selection.ts';
7
9
  export { FILENAME_FORMAT, formatFilename } from './filename.ts';
8
10
  export { CHECKSUMS_EXTENSION, CHECKSUM_ALGORITHMS, hashFile, hashFiles, createChecksums, writeChecksums, readChecksums, parseChecksums, compareChecksums, verifyChecksums, } from './checksums.ts';
9
- export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findMainPluginFile, readPackageMeta, } from './package-meta.ts';
11
+ export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findFileHeaderLine, findReadmeHeaderLine, findMainPluginFile, readPackageMeta, } from './package-meta.ts';
10
12
  export { SIGNATURE_EXTENSION, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, getPublicKey, getFileDigest, signFile, writeSignature, verifyFileSignature, } from './signing.ts';
11
13
  export { ZIP_MTIME, ZIP_FILE_MODE, createZip } from './zip.ts';
12
14
  export { isUrl, downloadFile } from './download.ts';
13
15
  export { readZipEntries, getZipEntryErrors, extractZip } from './unzip.ts';
14
- export { GIT_TAG_FORMAT, getGitTagVersion, findConstantValues, findVersionMismatches, } from './version-check.ts';
16
+ export { GIT_TAG_FORMAT, VersionMismatchError, getGitTagVersion, findConstantValues, findConstantDefinitions, findVersionMismatches, getVersionMismatchProblems, } from './version-check.ts';
15
17
  export { Svn, SvnError, createSvnRunner, parseStatus, toSvnPath, } from './svn.ts';
16
18
  export { syncDirectory, copyFileIfChanged } from './sync.ts';
17
19
  export { ASSET_MIME_TYPES, SvnDeploy } from './svn-deploy.ts';
@@ -21,7 +23,9 @@ export type { SignResult } from './commands/sign.ts';
21
23
  export type { ZipResult } from './commands/zip.ts';
22
24
  export type { Command, CommandContext, CommandOutcome, ParsedValues, } from './commands/command.ts';
23
25
  export type { DeployArgsInput } from './commands/deploy-svn.ts';
24
- export type { LoggerOptions, OutputStream } from './logger.ts';
26
+ export type { Annotation, LogEntry, LogLevel, LogWriter, LoggerOptions, OutputStream, SummaryBuilder, TextWriterOptions, } from './logger.ts';
27
+ export type { GitHubActionsWriterOptions } from './github-actions-writer.ts';
28
+ export type { Problem } from './errors.ts';
25
29
  export type { DeployInput, DeployOptions } from './deploy-options.ts';
26
30
  export type { FileRules, FileSelection, FileSelectionInput, } from './file-selection.ts';
27
31
  export type { FilenameValues } from './filename.ts';
@@ -32,4 +36,5 @@ export type { ExtractZipInput, ZipEntryInfo, ZipEntryType } from './unzip.ts';
32
36
  export type { FindVersionMismatchesInput, GitTagInput, VersionCheckResult, VersionMismatch, } from './version-check.ts';
33
37
  export type { SvnCheckoutInput, SvnCommandInput, SvnCommandOutput, SvnCommitInput, SvnCopyInput, SvnDepth, SvnOptions, SvnPathsInput, SvnPropsetInput, SvnRunner, SvnStatusEntry, SvnStatusType, SvnUpdateInput, } from './svn.ts';
34
38
  export type { SyncDirectoryInput, SyncResult } from './sync.ts';
39
+ export type { SummaryContext } from './summary.ts';
35
40
  export type { CheckoutPath, DeployResult } from './svn-deploy.ts';
package/dist/index.js CHANGED
@@ -1,17 +1,19 @@
1
1
  export { run, execute, commands, getHelp, getCommandHelp } from "./cli.js";
2
+ export { logError, logProblems } from "./report.js";
2
3
  export { getDeployInputFromArgs } from "./commands/deploy-svn.js";
3
4
  export { ValidationError, CredentialsError } from "./errors.js";
4
- export { Logger } from "./logger.js";
5
+ export { Logger, TextWriter } from "./logger.js";
6
+ export { GitHubActionsWriter } from "./github-actions-writer.js";
5
7
  export { SVN_URL, isPrerelease, normalizeTag, isTagRef, isValidSlug, isValidVersion, getDeployOptionErrors, getDeployOptionWarnings, resolveDeployOptions, } from "./deploy-options.js";
6
8
  export { DISTIGNORE_FILENAME, parseExclude, resolveFileSelection, loadFileRules, selectFiles, } from "./file-selection.js";
7
9
  export { FILENAME_FORMAT, formatFilename } from "./filename.js";
8
10
  export { CHECKSUMS_EXTENSION, CHECKSUM_ALGORITHMS, hashFile, hashFiles, createChecksums, writeChecksums, readChecksums, parseChecksums, compareChecksums, verifyChecksums, } from "./checksums.js";
9
- export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findMainPluginFile, readPackageMeta, } from "./package-meta.js";
11
+ export { HEADER_BYTES, PLUGIN_HEADERS, THEME_HEADERS, README_HEADERS, README_FILENAMES, parseFileHeaders, parseReadmeHeaders, readFileStart, findFileHeaderLine, findReadmeHeaderLine, findMainPluginFile, readPackageMeta, } from "./package-meta.js";
10
12
  export { SIGNATURE_EXTENSION, SIGNING_KEY_ENV, parseSigningKey, parsePublicKey, getPublicKey, getFileDigest, signFile, writeSignature, verifyFileSignature, } from "./signing.js";
11
13
  export { ZIP_MTIME, ZIP_FILE_MODE, createZip } from "./zip.js";
12
14
  export { isUrl, downloadFile } from "./download.js";
13
15
  export { readZipEntries, getZipEntryErrors, extractZip } from "./unzip.js";
14
- export { GIT_TAG_FORMAT, getGitTagVersion, findConstantValues, findVersionMismatches, } from "./version-check.js";
16
+ export { GIT_TAG_FORMAT, VersionMismatchError, getGitTagVersion, findConstantValues, findConstantDefinitions, findVersionMismatches, getVersionMismatchProblems, } from "./version-check.js";
15
17
  export { Svn, SvnError, createSvnRunner, parseStatus, toSvnPath, } from "./svn.js";
16
18
  export { syncDirectory, copyFileIfChanged } from "./sync.js";
17
19
  export { ASSET_MIME_TYPES, SvnDeploy } from "./svn-deploy.js";
package/dist/logger.d.ts CHANGED
@@ -1,36 +1,108 @@
1
+ import type { SummaryContext } from './summary.ts';
1
2
  /**
2
3
  * Stream that output is written to, like process.stdout.
3
4
  */
4
5
  export interface OutputStream {
5
6
  write: (text: string) => unknown;
6
7
  }
7
- export interface LoggerOptions {
8
- /** Use GitHub Actions workflow commands. */
9
- ci?: boolean;
10
- /** Print errors only. */
11
- quiet?: boolean;
12
- /** Print debug messages. */
13
- verbose?: boolean;
8
+ /**
9
+ * Log levels, from the most to the least verbose.
10
+ */
11
+ export type LogLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error';
12
+ /**
13
+ * File and title that a message is about, which writers like the GitHub
14
+ * Actions writer show as an annotation on the file.
15
+ */
16
+ export interface Annotation {
17
+ /** File the message is about, absolute or relative to the working directory. */
18
+ file?: string | null;
19
+ /** Line in the file, starting at 1. */
20
+ line?: number | null;
21
+ /** Annotation title. */
22
+ title?: string;
23
+ }
24
+ /**
25
+ * A message passed from the logger to its writer.
26
+ */
27
+ export interface LogEntry {
28
+ level: LogLevel;
29
+ message: string;
30
+ annotation: Annotation;
31
+ }
32
+ /**
33
+ * Builds the Markdown of a summary for the context of a writer.
34
+ */
35
+ export type SummaryBuilder = (context: SummaryContext) => string;
36
+ /**
37
+ * Destination of the log, like a terminal or a CI system. The logger decides
38
+ * which messages to write, and the writer decides how they look.
39
+ */
40
+ export interface LogWriter {
41
+ /** Write a message. */
42
+ write: (entry: LogEntry) => void;
43
+ /** Start a collapsible group of messages. */
44
+ startGroup: (title: string) => void;
45
+ /** End the group started last. */
46
+ endGroup: () => void;
47
+ /** Add to the summary of the run, for writers that have one. */
48
+ summary: (build: SummaryBuilder) => void;
49
+ }
50
+ export interface TextWriterOptions {
14
51
  /** Stream for regular output. */
15
52
  stdout?: OutputStream;
16
53
  /** Stream for warnings and errors. */
17
54
  stderr?: OutputStream;
18
55
  }
19
56
  /**
20
- * Logger that prints plain text locally and GitHub Actions workflow commands in CI.
57
+ * Writer that prints plain text, for terminals and CI systems without
58
+ * special log formats.
59
+ */
60
+ export declare class TextWriter implements LogWriter {
61
+ readonly stdout: OutputStream;
62
+ readonly stderr: OutputStream;
63
+ constructor({ stdout, stderr, }?: TextWriterOptions);
64
+ write({ level, message }: LogEntry): void;
65
+ startGroup(title: string): void;
66
+ endGroup(): void;
67
+ summary(): void;
68
+ }
69
+ export interface LoggerOptions {
70
+ /** Writer for the messages, a TextWriter by default. */
71
+ writer?: LogWriter;
72
+ /** Print errors and warnings only. */
73
+ quiet?: boolean;
74
+ /** Print debug messages. */
75
+ verbose?: boolean;
76
+ }
77
+ /**
78
+ * Logger that filters messages by level and passes them to a writer, which
79
+ * formats them for a terminal or a CI system.
21
80
  */
22
81
  export declare class Logger {
23
- readonly ci: boolean;
82
+ readonly writer: LogWriter;
24
83
  readonly quiet: boolean;
25
84
  readonly verbose: boolean;
26
- readonly stdout: OutputStream;
27
- readonly stderr: OutputStream;
28
- constructor({ ci, quiet, verbose, stdout, stderr, }?: LoggerOptions);
29
- info(message: string): void;
85
+ constructor({ writer, quiet, verbose, }?: LoggerOptions);
86
+ /**
87
+ * Write a message unless its level is filtered out.
88
+ *
89
+ * @param level Log level.
90
+ * @param message Message.
91
+ * @param annotation File, line and title the message is about.
92
+ */
93
+ log(level: LogLevel, message: string, annotation?: Annotation): void;
30
94
  debug(message: string): void;
31
- notice(message: string): void;
32
- warning(message: string): void;
33
- error(message: string): void;
95
+ info(message: string): void;
96
+ notice(message: string, annotation?: Annotation): void;
97
+ warning(message: string, annotation?: Annotation): void;
98
+ error(message: string, annotation?: Annotation): void;
99
+ /**
100
+ * Add to the summary of the run. Only writers with a summary, like the
101
+ * GitHub Actions job summary, call the builder.
102
+ *
103
+ * @param build Builds the summary Markdown.
104
+ */
105
+ summary(build: SummaryBuilder): void;
34
106
  /**
35
107
  * Run a callback inside a collapsible log group.
36
108
  *
package/dist/logger.js CHANGED
@@ -1,39 +1,85 @@
1
1
  /**
2
- * Logger that prints plain text locally and GitHub Actions workflow commands in CI.
2
+ * Writer that prints plain text, for terminals and CI systems without
3
+ * special log formats.
4
+ */
5
+ export class TextWriter {
6
+ stdout;
7
+ stderr;
8
+ constructor({ stdout = process.stdout, stderr = process.stderr, } = {}) {
9
+ this.stdout = stdout;
10
+ this.stderr = stderr;
11
+ }
12
+ write({ level, message }) {
13
+ const prefixes = {
14
+ warning: 'Warning: ',
15
+ error: 'Error: ',
16
+ };
17
+ const stream = 'warning' === level || 'error' === level
18
+ ? this.stderr
19
+ : this.stdout;
20
+ stream.write(`${prefixes[level] ?? ''}${message}\n`);
21
+ }
22
+ startGroup(title) {
23
+ this.stdout.write(`== ${title}\n`);
24
+ }
25
+ endGroup() { }
26
+ summary() { }
27
+ }
28
+ /**
29
+ * Logger that filters messages by level and passes them to a writer, which
30
+ * formats them for a terminal or a CI system.
3
31
  */
4
32
  export class Logger {
5
- ci;
33
+ writer;
6
34
  quiet;
7
35
  verbose;
8
- stdout;
9
- stderr;
10
- constructor({ ci = 'true' === process.env.GITHUB_ACTIONS, quiet = false, verbose = false, stdout = process.stdout, stderr = process.stderr, } = {}) {
11
- this.ci = ci;
36
+ constructor({ writer = new TextWriter(), quiet = false, verbose = false, } = {}) {
37
+ this.writer = writer;
12
38
  this.quiet = quiet;
13
39
  this.verbose = verbose;
14
- this.stdout = stdout;
15
- this.stderr = stderr;
16
40
  }
17
- info(message) {
18
- if (!this.quiet) {
19
- this.stdout.write(`${message}\n`);
41
+ /**
42
+ * Write a message unless its level is filtered out.
43
+ *
44
+ * @param level Log level.
45
+ * @param message Message.
46
+ * @param annotation File, line and title the message is about.
47
+ */
48
+ log(level, message, annotation = {}) {
49
+ const shown = {
50
+ debug: this.verbose && !this.quiet,
51
+ info: !this.quiet,
52
+ notice: !this.quiet,
53
+ warning: true,
54
+ error: true,
55
+ }[level];
56
+ if (shown) {
57
+ this.writer.write({ level, message, annotation });
20
58
  }
21
59
  }
22
60
  debug(message) {
23
- if (this.verbose && !this.quiet) {
24
- this.stdout.write(this.ci ? `::debug::${message}\n` : `${message}\n`);
25
- }
61
+ this.log('debug', message);
26
62
  }
27
- notice(message) {
28
- if (!this.quiet) {
29
- this.stdout.write(this.ci ? `::notice::${message}\n` : `${message}\n`);
30
- }
63
+ info(message) {
64
+ this.log('info', message);
31
65
  }
32
- warning(message) {
33
- this.stderr.write(this.ci ? `::warning::${message}\n` : `Warning: ${message}\n`);
66
+ notice(message, annotation = {}) {
67
+ this.log('notice', message, annotation);
34
68
  }
35
- error(message) {
36
- this.stderr.write(this.ci ? `::error::${message}\n` : `Error: ${message}\n`);
69
+ warning(message, annotation = {}) {
70
+ this.log('warning', message, annotation);
71
+ }
72
+ error(message, annotation = {}) {
73
+ this.log('error', message, annotation);
74
+ }
75
+ /**
76
+ * Add to the summary of the run. Only writers with a summary, like the
77
+ * GitHub Actions job summary, call the builder.
78
+ *
79
+ * @param build Builds the summary Markdown.
80
+ */
81
+ summary(build) {
82
+ this.writer.summary(build);
37
83
  }
38
84
  /**
39
85
  * Run a callback inside a collapsible log group.
@@ -43,13 +89,15 @@ export class Logger {
43
89
  * @return Callback result.
44
90
  */
45
91
  async group(title, callback) {
46
- this.info(this.ci ? `::group::${title}` : `== ${title}`);
92
+ if (!this.quiet) {
93
+ this.writer.startGroup(title);
94
+ }
47
95
  try {
48
96
  return await callback();
49
97
  }
50
98
  finally {
51
- if (this.ci) {
52
- this.info('::endgroup::');
99
+ if (!this.quiet) {
100
+ this.writer.endGroup();
53
101
  }
54
102
  }
55
103
  }
@@ -76,6 +76,23 @@ export interface ReadPackageMetaInput {
76
76
  * @return Non-empty header values.
77
77
  */
78
78
  export declare function parseFileHeaders(input: ParseFileHeadersInput): HeaderValues;
79
+ /**
80
+ * Find the line of a file header like "Version: 1.2.0", matched the same way
81
+ * as parseFileHeaders().
82
+ *
83
+ * @param contents File contents.
84
+ * @param header Header name.
85
+ * @return Line number starting at 1, or null when the header isn't there.
86
+ */
87
+ export declare function findFileHeaderLine(contents: string, header: string): number | null;
88
+ /**
89
+ * Get the line number of a position in a text.
90
+ *
91
+ * @param contents Text.
92
+ * @param index Character position.
93
+ * @return Line number starting at 1.
94
+ */
95
+ export declare function getLineNumber(contents: string, index: number): number;
79
96
  /**
80
97
  * Parse the headers of a readme.txt or readme.md, the "Key: value" lines
81
98
  * between the title and the first section. Markdown emphasis around the
@@ -85,6 +102,15 @@ export declare function parseFileHeaders(input: ParseFileHeadersInput): HeaderVa
85
102
  * @return Non-empty header values keyed by the names in README_HEADERS.
86
103
  */
87
104
  export declare function parseReadmeHeaders(contents: string): HeaderValues;
105
+ /**
106
+ * Find the line of a readme header like "Stable tag: 1.2.0", the line that
107
+ * parseReadmeHeaders() reads the value from.
108
+ *
109
+ * @param contents Readme contents.
110
+ * @param header Header name from README_HEADERS.
111
+ * @return Line number starting at 1, or null when the header isn't there.
112
+ */
113
+ export declare function findReadmeHeaderLine(contents: string, header: string): number | null;
88
114
  /**
89
115
  * Read the start of a file, where WordPress looks for the headers.
90
116
  *
@@ -82,11 +82,36 @@ export function parseFileHeaders(input) {
82
82
  ]);
83
83
  return Object.fromEntries(entries.filter(([, value]) => value));
84
84
  }
85
+ function getFileHeaderPattern(header) {
86
+ return new RegExp(`^(?:[ \\t]*<\\?php)?[ \\t/*#@]*${escapeRegExp(header)}:(.*)$`, 'mi');
87
+ }
85
88
  function matchFileHeader(contents, header) {
86
- const pattern = new RegExp(`^(?:[ \\t]*<\\?php)?[ \\t/*#@]*${escapeRegExp(header)}:(.*)$`, 'mi');
87
- const value = pattern.exec(contents)?.[1] ?? '';
89
+ const value = getFileHeaderPattern(header).exec(contents)?.[1] ?? '';
88
90
  return value.replace(/\s*(?:\*\/|\?>).*/, '').trim();
89
91
  }
92
+ /**
93
+ * Find the line of a file header like "Version: 1.2.0", matched the same way
94
+ * as parseFileHeaders().
95
+ *
96
+ * @param contents File contents.
97
+ * @param header Header name.
98
+ * @return Line number starting at 1, or null when the header isn't there.
99
+ */
100
+ export function findFileHeaderLine(contents, header) {
101
+ const normalized = contents.replace(/\r\n?/g, '\n');
102
+ const match = getFileHeaderPattern(header).exec(normalized);
103
+ return match ? getLineNumber(normalized, match.index) : null;
104
+ }
105
+ /**
106
+ * Get the line number of a position in a text.
107
+ *
108
+ * @param contents Text.
109
+ * @param index Character position.
110
+ * @return Line number starting at 1.
111
+ */
112
+ export function getLineNumber(contents, index) {
113
+ return contents.slice(0, index).split('\n').length;
114
+ }
90
115
  function escapeRegExp(text) {
91
116
  return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
117
  }
@@ -99,19 +124,49 @@ function escapeRegExp(text) {
99
124
  * @return Non-empty header values keyed by the names in README_HEADERS.
100
125
  */
101
126
  export function parseReadmeHeaders(contents) {
102
- const lines = contents.replace(/\r\n?/g, '\n').split('\n');
103
- const firstSection = lines.findIndex((line, index) => index > 0 && /^\s*(==|##)/.test(line));
104
- const headerLines = lines.slice(0, -1 === firstSection ? lines.length : firstSection);
105
127
  const values = {};
106
- headerLines.forEach((line) => {
107
- const match = /^[\s*_-]*([^:*_]+?)[*_]*\s*:[*_]*\s*(.*?)\s*$/.exec(line);
108
- const header = README_HEADERS.find((name) => name.toLowerCase() === match?.[1]?.toLowerCase());
109
- if (header && match?.[2] && !Object.hasOwn(values, header)) {
110
- values[header] = match[2];
128
+ getReadmeHeaderLines(contents).forEach((line) => {
129
+ const match = matchReadmeHeader(line);
130
+ if (match && !Object.hasOwn(values, match.header)) {
131
+ values[match.header] = match.value;
111
132
  }
112
133
  });
113
134
  return values;
114
135
  }
136
+ /**
137
+ * Find the line of a readme header like "Stable tag: 1.2.0", the line that
138
+ * parseReadmeHeaders() reads the value from.
139
+ *
140
+ * @param contents Readme contents.
141
+ * @param header Header name from README_HEADERS.
142
+ * @return Line number starting at 1, or null when the header isn't there.
143
+ */
144
+ export function findReadmeHeaderLine(contents, header) {
145
+ const index = getReadmeHeaderLines(contents).findIndex((line) => header === matchReadmeHeader(line)?.header);
146
+ return -1 === index ? null : index + 1;
147
+ }
148
+ /**
149
+ * Get the readme lines before the first section, where the headers are.
150
+ *
151
+ * @param contents Readme contents.
152
+ * @return Lines from the first line.
153
+ */
154
+ function getReadmeHeaderLines(contents) {
155
+ const lines = contents.replace(/\r\n?/g, '\n').split('\n');
156
+ const firstSection = lines.findIndex((line, index) => index > 0 && /^\s*(==|##)/.test(line));
157
+ return lines.slice(0, -1 === firstSection ? lines.length : firstSection);
158
+ }
159
+ /**
160
+ * Match a readme header line. Markdown emphasis around the name is ignored.
161
+ *
162
+ * @param line Readme line.
163
+ * @return Header name from README_HEADERS and its value, or null when the line isn't a non-empty header.
164
+ */
165
+ function matchReadmeHeader(line) {
166
+ const match = /^[\s*_-]*([^:*_]+?)[*_]*\s*:[*_]*\s*(.*?)\s*$/.exec(line);
167
+ const header = README_HEADERS.find((name) => name.toLowerCase() === match?.[1]?.toLowerCase());
168
+ return header && match?.[2] ? { header, value: match[2] } : null;
169
+ }
115
170
  /**
116
171
  * Read the start of a file, where WordPress looks for the headers.
117
172
  *