Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 27 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,17 @@ linear-release update --stage="in review" --name="Release 1.2.0"

### CLI Options

| Option | Commands | Description |
| ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. |
| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. |
| `--stage` | `update` | Target deployment stage (required for `update`) |
| `--include-paths` | `sync` | Filter commits by changed file paths |
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |
| Option | Commands | Description |
| -------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` | `sync`, `complete`, `update` | Custom release name. For `sync`, the value is applied to the targeted release — both newly created releases and existing ones get the provided name. For `complete` and `update`, sets the name on the targeted release. |
| `--release-version` | `sync`, `complete`, `update` | Release version identifier. For `sync`, defaults to short commit hash. For `complete` and `update`, selects an existing release with that version (errors if none exists); does not change a release's version. If omitted, targets the most recent started release. |
| `--stage` | `update` | Target deployment stage (required for `update`) |
| `--include-paths` | `sync` | Filter commits by changed file paths |
| `--include-subjects` | `sync` | Filter commits whose subject (first line) matches a regex |
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |

### Command Targeting

Expand Down Expand Up @@ -209,6 +210,22 @@ Patterns use [Git pathspec](https://git-scm.com/docs/gitglossary#Documentation/g

Path patterns can also be configured in your pipeline settings in Linear. If both are set, the CLI `--include-paths` option takes precedence.

### Subject Filtering

Use `--include-subjects` to only scan commits whose subject (first line) matches a regular expression. Useful when the default commit range pulls in noise — direct pushes without issue links, bot commits, or merge commits you don't want appearing in releases.

```bash
# Only commits that mention a Linear issue identifier in the subject
linear-release sync --include-subjects="[A-Z]{2,}-[0-9]+"

# Conventional Commits — keep user-impacting changes, drop chore/docs/test/ci
linear-release sync --include-subjects="^(feat|fix|perf):"
```

The regex is matched against the commit subject only (everything before the first newline) — body lines such as squash dumps or co-author trailers are ignored. Use the regex's own `|` alternation to combine multiple patterns; remember to escape regex metacharacters in shell strings.

`--include-subjects` composes with `--include-paths`: a commit must pass both filters to be scanned.

## How It Works

1. **Fetches the latest release** from your Linear pipeline to determine the commit range
Expand Down
19 changes: 19 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,25 @@ describe("parseCLIArgs", () => {
expect(result.includePaths).toEqual(["apps/web/**", "packages/**"]);
});

it("defaults --include-subjects to null", () => {
const result = parseCLIArgs([]);
expect(result.includeSubjects).toBeNull();
});

it("returns --include-subjects as the raw pattern string", () => {
const result = parseCLIArgs(["--include-subjects", "^(feat|fix):"]);
expect(result.includeSubjects).toBe("^(feat|fix):");
});

it("treats empty --include-subjects as no filter", () => {
const result = parseCLIArgs(["--include-subjects", ""]);
expect(result.includeSubjects).toBeNull();
});

it("throws a helpful error on invalid --include-subjects regex", () => {
expect(() => parseCLIArgs(["--include-subjects", "([unclosed"])).toThrow(/Invalid --include-subjects regex/);
});

it("throws on unknown flags (strict mode)", () => {
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
});
Expand Down
15 changes: 15 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type ParsedCLIArgs = {
releaseVersion?: string;
stageName?: string;
includePaths: string[];
includeSubjects: string | null;
jsonOutput: boolean;
timeoutSeconds: number;
logLevel: LogLevel;
Expand All @@ -20,6 +21,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
"release-version": { type: "string" },
stage: { type: "string" },
"include-paths": { type: "string" },
"include-subjects": { type: "string" },
json: { type: "boolean", default: false },
timeout: { type: "string" },
quiet: { type: "boolean", default: false },
Expand Down Expand Up @@ -47,6 +49,18 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
if (values.quiet) logLevel = LogLevel.Quiet;
else if (values.verbose) logLevel = LogLevel.Verbose;

let includeSubjects: string | null = null;
const rawIncludeSubjects = values["include-subjects"];
if (rawIncludeSubjects !== undefined && rawIncludeSubjects.length > 0) {
try {
new RegExp(rawIncludeSubjects);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Invalid --include-subjects regex: ${detail}`);
}
includeSubjects = rawIncludeSubjects;
}

return {
command: positionals[0] || "sync",
releaseName: values.name,
Expand All @@ -58,6 +72,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
.map((p) => p.trim())
.filter((p) => p.length > 0)
: [],
includeSubjects,
jsonOutput: values.json ?? false,
timeoutSeconds,
logLevel,
Expand Down
19 changes: 18 additions & 1 deletion src/extractors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ function matchAllIdentifiers(text: string): IdentifierMatch[] {
* convention itself signals intent.
*/
function matchCommonSubjectPatterns(message: string): IdentifierMatch[] {
const subject = message.split(/\r?\n/)[0] ?? "";
const subject = getCommitSubject(message);
const results: IdentifierMatch[] = [];
for (const pattern of COMMON_SUBJECT_PATTERNS) {
const match = subject.match(pattern);
Expand Down Expand Up @@ -396,6 +396,23 @@ export function getRevertBranchDepth(branchName: string | null | undefined): num
return parseRevertBranch(branchName).depth;
}

export function getCommitSubject(message: string | null | undefined): string {
if (!message) return "";
const newlineIdx = message.search(/\r?\n/);
return newlineIdx === -1 ? message : message.slice(0, newlineIdx);
}

/**
* Returns the subject with any `Revert "..."` wrapping stripped. For a
* non-revert commit this is just the subject; for a revert it's the subject of
* the commit being reverted. Callers that want to match against what the
* change is *about* (not the revert mechanics) should use this.
*/
export function getEffectiveSubject(message: string | null | undefined): string {
if (!message) return "";
return parseRevertMessage(message).inner;
}

/**
* Unwrap `Revert "..."` layers on the subject line only. Scanning the whole
* message would let a stray `"` in the body extend the capture past the real
Expand Down
23 changes: 17 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Options:
--release-version=<version> Release version identifier
--stage=<stage> Deployment stage (required for update)
--include-paths=<paths> Filter commits by file paths (comma-separated globs)
--include-subjects=<regex> Filter commits whose subject (first line) matches the regex
--timeout=<seconds> Abort if the operation exceeds this duration (default: 60)
--json Output result as JSON (logs emitted as JSON Lines on stderr)
--quiet Suppress info-level output (warnings and errors still printed)
Expand All @@ -67,6 +68,7 @@ Examples:
linear-release complete
linear-release update --stage=production
linear-release sync --include-paths="apps/web/**,packages/**"
linear-release sync --include-subjects="[A-Z]{2,}-[0-9]+"
`);
process.exit(0);
}
Expand All @@ -86,8 +88,17 @@ try {
error(`${message} (run linear-release --help for usage)`);
process.exit(1);
}
const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } =
parsedArgs;
const {
command,
releaseName,
releaseVersion,
stageName,
includePaths,
includeSubjects,
jsonOutput,
timeoutSeconds,
logLevel,
} = parsedArgs;
const cliWarnings = getCLIWarnings(parsedArgs);
setLogLevel(logLevel);
if (jsonOutput) {
Expand Down Expand Up @@ -210,10 +221,10 @@ async function syncCommand(): Promise<{
// git log returns newest-first; scanCommits needs chronological (oldest-first) for last-write-wins
commits.reverse();

const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(
commits,
effectiveIncludePaths,
);
const { issueReferences, revertedIssueReferences, prNumbers, debugSink } = scanCommits(commits, {
includePaths: effectiveIncludePaths,
includeSubjects,
});

verbose(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`);

Expand Down
Loading
Loading