Skip to content
Merged
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
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ linear-release update --stage="in review" --name="Release 1.2.0"
| `--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 |
| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `<base-ref>..HEAD`. |
| `--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 |
Expand Down Expand Up @@ -220,10 +221,23 @@ Path patterns can also be configured in your pipeline settings in Linear. If bot
> [!NOTE]
> **First sync**: when no prior release exists for the pipeline, only the current commit is scanned (there's no previous SHA to bound the range from).

### Overriding the Scan Base

Use `--base-ref` to explicitly choose the exclusive lower bound for `sync`'s commit scan. This is useful when the automatically selected release baseline is not the range you want for a custom branching workflow, first-time onboarding, or migration.

```bash
linear-release sync --base-ref=<last-released-ref> --include-paths="apps/api/**"
```

The base ref is exclusive: linear-release scans `<base-ref>..HEAD`, matching Git range syntax, and still applies any configured path filters. Pass the last commit, tag, or ref that should be treated as already released, not the first commit you want included.

When `--base-ref` is provided, it overrides automatic base selection for that run. After sync, current `HEAD` is stored as the future release baseline. Choosing an older or newer base can reattach or skip commits, so use this only when you intentionally want to own the scan range.

## Troubleshooting

- **Unexpected release was updated/completed**: pass `--release-version` explicitly so the command does not target the latest started/planned release.
- **No release created by `sync`**: if no commits match the computed range (or path filters), `sync` returns `{"release":null}`.
- **No release created by `sync`**: without `--base-ref`, if no commits match the computed range (or path filters), `sync` returns `{"release":null}`.
- **Need to backfill the first release, migrate rewritten history, or override the inferred range**: run `sync` with `--base-ref=<ref>` to set an explicit scan base.
- **Stage update fails**: `--stage` matches first by exact name, then case-insensitively with dashes and underscores treated as spaces. If multiple stages normalize to the same value, pass the exact stage name to disambiguate.
- **`sync --release-version` fails because the matching release is archived**: restore the archived release in Linear before re-syncing.
- **Operation timed out**: the CLI aborts after 60 seconds by default. For large repositories or slow networks, increase the limit with `--timeout=120`.
Expand Down
10 changes: 10 additions & 0 deletions src/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ describe("parseCLIArgs", () => {
expect(result.stageName).toBe("production");
});

it("parses --base-ref", () => {
const result = parseCLIArgs(["--base-ref", "v1.2.3"]);
expect(result.baseRef).toBe("v1.2.3");
});

it("parses --base-ref with = syntax", () => {
const result = parseCLIArgs(["--base-ref=main~5"]);
expect(result.baseRef).toBe("main~5");
});

it("defaults --json to false", () => {
const result = parseCLIArgs([]);
expect(result.jsonOutput).toBe(false);
Expand Down
3 changes: 3 additions & 0 deletions src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type ParsedCLIArgs = {
releaseName?: string;
releaseVersion?: string;
stageName?: string;
baseRef?: string;
includePaths: string[];
jsonOutput: boolean;
timeoutSeconds: number;
Expand All @@ -19,6 +20,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
name: { type: "string" },
"release-version": { type: "string" },
stage: { type: "string" },
"base-ref": { type: "string" },
"include-paths": { type: "string" },
json: { type: "boolean", default: false },
timeout: { type: "string" },
Expand Down Expand Up @@ -52,6 +54,7 @@ export function parseCLIArgs(argv: string[]): ParsedCLIArgs {
releaseName: values.name,
releaseVersion: values["release-version"],
stageName: values.stage,
baseRef: values["base-ref"],
includePaths: values["include-paths"]
? values["include-paths"]
.split(",")
Expand Down
52 changes: 46 additions & 6 deletions src/git.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execSync } from "node:child_process";
import { execFileSync, execSync } from "node:child_process";
import type { CommitContext, GitInfo, RepoInfo } from "./types";
import { error as logError, verbose, warn } from "./log";

Expand Down Expand Up @@ -263,6 +263,43 @@ export function verifyAncestorReachable(sha: string, headSha: string, cwd: strin

const SHA_PATTERN = /^[0-9a-f]{7,40}$/i;

/**
* Resolves a git ref, tag, or SHA to a full commit SHA.
*
* Shallow or single-branch clones often lack the target locally:
* - SHA-like inputs: deepen history until the commit is reachable.
* - Tag or branch refs: `git fetch origin <ref>` populates FETCH_HEAD with
* the resolved commit for both kinds, without needing to know which.
*/
export function resolveCommitRef(ref: string, cwd: string = process.cwd()): string {
const resolve = (target: string = ref) =>
execFileSync("git", ["rev-parse", "--verify", `${target}^{commit}`], {
cwd,
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
}).trim();

try {
return resolve();
} catch {
if (SHA_PATTERN.test(ref)) {
ensureCommitAvailable(ref, cwd);
return resolve();
}
try {
verbose(`Ref "${ref}" not in local history; fetching from origin`);
execFileSync("git", ["fetch", "origin", ref], {
cwd,
stdio: ["ignore", "ignore", "ignore"],
timeout: 30_000,
});
return resolve("FETCH_HEAD");
} catch {
throw new Error(`Could not resolve "${ref}" to a commit. Use a valid commit SHA, tag, or ref.`);
}
}
}

/**
* Extracts the branch name from a merge commit message.
* Supports:
Expand Down Expand Up @@ -369,19 +406,21 @@ function runLog(rangeArgs: string, cwd: string): CommitContext[] {
* `--no-walk` (only when `fromSha === toSha`): without it, `git log -1 <sha>
* -- <paths>` walks back from `<sha>` to the first ancestor matching the
* pathspec — silently returning an unrelated commit when `<sha>` itself
* doesn't match.
* doesn't match. Callers that need true `sha..sha` empty-range semantics can
* pass `inspectSingleCommit: false`.
*
* @param fromSha - Starting commit SHA (exclusive)
* @param toSha - Ending commit SHA (inclusive)
* @param options.includePaths - Glob patterns to filter commits by file paths (relative to repo root)
* @param options.inspectSingleCommit - When SHAs match, inspect that one commit instead of treating it as an empty range
* @param options.cwd - Working directory for git commands (defaults to process.cwd())
*/
export function getCommitContextsBetweenShas(
fromSha: string,
toSha: string,
options: { includePaths?: string[] | null; cwd?: string } = {},
options: { includePaths?: string[] | null; inspectSingleCommit?: boolean; cwd?: string } = {},
): CommitContext[] {
const { includePaths = null, cwd = process.cwd() } = options;
const { includePaths = null, inspectSingleCommit = true, cwd = process.cwd() } = options;

if (!SHA_PATTERN.test(fromSha)) {
warn(`Invalid "from" SHA format "${fromSha}"`);
Expand All @@ -392,17 +431,18 @@ export function getCommitContextsBetweenShas(
return [];
}

const inspectingSingleCommit = fromSha === toSha && inspectSingleCommit;
const args = [
includePaths?.length ? "--full-history" : "",
fromSha === toSha ? `--no-walk ${toSha}` : `${fromSha}..${toSha}`,
inspectingSingleCommit ? `--no-walk ${toSha}` : `${fromSha}..${toSha}`,
buildPathspecArgs(includePaths),
]
.filter(Boolean)
.join(" ");
const commits = runLog(args, cwd);

if (commits.length === 0) {
if (fromSha === toSha) {
if (inspectingSingleCommit) {
const pathFilter = includePaths?.length ? ` include paths: ${includePaths.join(", ")}` : "";
verbose(`Commit ${toSha.slice(0, 7)} did not match${pathFilter}`);
} else {
Expand Down
100 changes: 63 additions & 37 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import {
getCommitContextsBetweenShas,
getCurrentGitInfo,
getRepoInfo,
resolveFirstSyncBoundary,
resolveCommitRef,
verifyAncestorReachable,
} from "./git";
import { findBaseSha } from "./base-sha";
import { assertBaseRefIsAncestor, ScanBase, selectAutomaticScanBase, shouldCreateReleaseForScan } from "./scan-base";
import { scanCommits } from "./scan";
import {
Release,
Expand Down 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)
--base-ref=<ref> Override sync scan base (exclusive; scans <ref>..HEAD)
--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 --base-ref=<last-released-ref> --include-paths="apps/web/**"
`);
process.exit(0);
}
Expand All @@ -86,7 +88,7 @@ try {
error(`${message} (run linear-release --help for usage)`);
process.exit(1);
}
const { command, releaseName, releaseVersion, stageName, includePaths, jsonOutput, timeoutSeconds, logLevel } =
const { command, releaseName, releaseVersion, stageName, baseRef, includePaths, jsonOutput, timeoutSeconds, logLevel } =
parsedArgs;
const cliWarnings = getCLIWarnings(parsedArgs);
setLogLevel(logLevel);
Expand Down Expand Up @@ -165,22 +167,33 @@ async function syncCommand(): Promise<{
throw new Error("Could not get current commit");
}

let latestSha = await getLatestSha();
const recentReleases = await getRecentReleases();
const scanBase = getScanBase(recentReleases, currentCommit.commit);
let latestSha = scanBase.sha;
let inspectingOnlyCurrentCommit = false;

try {
ensureCommitAvailable(latestSha);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warn(
`Could not make sha ${latestSha} available in local git history; falling back to current commit only. ${message}`,
);
inspectingOnlyCurrentCommit = true;
latestSha = currentCommit.commit;
if (scanBase.kind === "base-ref") {
assertBaseRefIsAncestor(scanBase.ref, latestSha, currentCommit.commit, { verifyAncestorReachable });
const includePathSummary = effectiveIncludePaths?.length
? ` with include paths: ${effectiveIncludePaths.join(", ")}`
: "";
info(`Scanning ${latestSha.slice(0, 7)}..${currentCommit.commit.slice(0, 7)}${includePathSummary}`);
} else {
try {
ensureCommitAvailable(latestSha);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warn(
`Could not make sha ${latestSha} available in local git history; falling back to current commit only. ${message}`,
);
inspectingOnlyCurrentCommit = true;
latestSha = currentCommit.commit;
}
}

const commits = getCommitContextsBetweenShas(latestSha, currentCommit.commit, {
includePaths: effectiveIncludePaths,
inspectSingleCommit: scanBase.kind !== "base-ref",
});

if (inspectingOnlyCurrentCommit) {
Expand All @@ -195,7 +208,9 @@ async function syncCommand(): Promise<{
}
} else {
const commitNoun = effectiveIncludePaths?.length ? "matching commit" : "commit";
if (latestSha === currentCommit.commit) {
if (scanBase.kind === "base-ref") {
info(`Found ${commits.length} ${pluralize(commits.length, commitNoun)} in requested range`);
} else if (latestSha === currentCommit.commit) {
info(
`Inspected current commit ${currentCommit.commit.slice(0, 7)}; found ${commits.length} ${pluralize(commits.length, commitNoun)}`,
);
Expand All @@ -207,14 +222,16 @@ async function syncCommand(): Promise<{
}

if (commits.length === 0) {
if (effectiveIncludePaths?.length) {
info(
`No matching commits found for include paths: ${effectiveIncludePaths.join(", ")}. Skipping release creation.`,
);
} else {
info("No commits found in the computed range. Skipping release creation.");
const reason = effectiveIncludePaths?.length
? `No matching commits found for include paths: ${effectiveIncludePaths.join(", ")}`
: scanBase.kind === "base-ref"
? "No commits found in the requested range"
: "No commits found in the computed range";
if (!shouldCreateReleaseForScan(commits.length, scanBase)) {
info(`${reason}. Skipping release creation.`);
return null;
}
return null;
info(`${reason}. Syncing release anyway because --base-ref was provided to establish the baseline.`);
}

// git log returns newest-first; scanCommits needs chronological (oldest-first) for last-write-wins
Expand All @@ -240,6 +257,9 @@ async function syncCommand(): Promise<{
if (prNumbers.length > 0) parts.push(`pull requests [${prNumbers.map((n) => `#${n}`).join(", ")}]`);
const attached = parts.length > 0 ? parts.join(", ") : "no new issues or pull requests";
info(`Synced to release ${release.name} (${formatVersion(release)}): ${attached}`);
if (scanBase.kind === "base-ref") {
info(`Stored release baseline: ${(release.commitSha ?? currentCommit.commit).slice(0, 7)}`);
}

return {
release: {
Expand Down Expand Up @@ -344,19 +364,25 @@ async function getRecentReleases(): Promise<Release[]> {
return response.data.recentReleasesByAccessKey;
}

async function getLatestSha(): Promise<string> {
const currentSha = getCurrentGitInfo().commit;
if (!currentSha) {
throw new Error("Could not get current commit");
function getScanBase(candidates: Release[], currentSha: string): ScanBase {
if (baseRef) {
let resolvedSha: string;
try {
resolvedSha = resolveCommitRef(baseRef);
} catch (e) {
const detail = e instanceof Error ? e.message : String(e);
throw new Error(`Invalid --base-ref: ${detail}`);
}
info(`Using --base-ref ${baseRef} (${resolvedSha.slice(0, 7)}); skipping automatic baseline selection`);
return { kind: "base-ref", sha: resolvedSha, ref: baseRef };
}

const candidates = await getRecentReleases();
const result = findBaseSha(candidates, currentSha, { verifyAncestorReachable });
if (result.kind === "found") {
return result.sha;
const scanBase = selectAutomaticScanBase(candidates, currentSha, { verifyAncestorReachable });
if (scanBase.kind !== "first-sync") {
return scanBase;
}

if (candidates.length === 0) {
if (scanBase.candidatesConsidered === 0) {
verbose("No recent releases found; assuming first sync");
} else {
// The candidate list came back non-empty but no entry is reachable from
Expand All @@ -368,20 +394,20 @@ async function getLatestSha(): Promise<string> {
// resolveFirstSyncBoundary, which uses HEAD^1 when HEAD is a merge commit.
// The follow-up verbose lines below print the boundary that was chosen.
warn(
`No recent release is an ancestor of ${currentSha} (${candidates.length} candidate${
candidates.length === 1 ? "" : "s"
} considered); falling back to the first-sync scan boundary`,
`No recent release is an ancestor of ${currentSha} (${scanBase.candidatesConsidered} ${pluralize(
scanBase.candidatesConsidered,
"candidate",
)} considered); falling back to the first-sync scan boundary`,
);
}
// For a merge HEAD the issue keys live on HEAD^2's branch, not on HEAD
// itself, so HEAD-only would miss them. Non-merge HEAD carries its own key.
const boundary = resolveFirstSyncBoundary(currentSha);
if (boundary !== currentSha) {
verbose(`Merge HEAD: using HEAD^1 (${boundary}) as the scan boundary`);
if (scanBase.sha !== currentSha) {
verbose(`Merge HEAD: using HEAD^1 (${scanBase.sha}) as the scan boundary`);
} else {
verbose("Inspecting current commit only");
}
return boundary;
return scanBase;
}

async function getPipelineSettings(): Promise<{
Expand Down
Loading
Loading