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
51 changes: 39 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,18 +149,22 @@ 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 |
| `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. |
| `--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 |
| `--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 |
| `--link` | `sync`, `complete`, `update` | Add a link to the targeted release. Use `--link "https://example.com"` or `--link "Label=https://example.com"`; repeat the flag to add multiple links. |
| `--document` | `sync`, `complete`, `update` | Attach a document. `--document "Title=...markdown..."`; repeat for multiple docs. Existing documents with the same title on the release are updated. |
| `--document-file` | `sync`, `complete`, `update` | Same as `--document` but reads the body from a file: `--document-file "Title=path/to/file.md"`. Use `-` to read from stdin. |
| `--release-notes` | `sync`, `complete`, `update` | Set the release notes for this release. Inline markdown. If combined with `--release-notes-file`, the last flag wins. |
| `--release-notes-file` | `sync`, `complete`, `update` | Same as `--release-notes` but reads from a file. Use `-` for stdin. |
| `--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 |
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |

### Command Targeting

Expand Down Expand Up @@ -231,6 +235,29 @@ linear-release complete --release-version="1.2.0" \

Each value is either an absolute URL or `Label=URL`. Both `--link "Label=..."` and `--link="Label=..."` are accepted. `http(s)` is the typical scheme; the server rejects unsafe ones like `javascript:` or `data:`.

### Documents and release notes

Attach release notes and supporting documents to a release. Each release has at most one set of release notes (last `--release-notes` / `--release-notes-file` wins). Documents are repeatable and keyed by title — re-syncing with the same title updates content in place.

```bash
# Release notes from a generated changelog
linear-release sync --release-notes-file ./CHANGELOG.md

# Plus extra documents (deploy log, runbook, etc.)
linear-release sync \
--release-notes-file ./CHANGELOG.md \
--document-file "Deploy log=./deploy.log" \
--document-file "Runbook=./runbook.md"

# Stdin works on both flags — useful when piping from another command
git log v1.0.0..HEAD --format="- %s" | linear-release sync --release-notes-file -

# Inline (single-line content only — see "Multi-line content" below)
linear-release sync --document "Deploy log=Deployed to production at $(date -u +%FT%TZ)"
```

> **Multi-line content**: use `--document-file` / `--release-notes-file`. Inline `\n` inside `"…"` is passed verbatim by the shell — same gotcha as `gh release create --notes`, `git commit -m`, and `helm --set`. For inline multi-line, use a real newline in the quotes or [`$'…\n…'`](https://www.gnu.org/software/bash/manual/html_node/ANSI_002dC-Quoting.html).

## How It Works

1. **Fetches the latest release** from your Linear pipeline to determine the commit range
Expand Down
201 changes: 185 additions & 16 deletions src/args.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { getCLIWarnings, parseCLIArgs } from "./args";
import { parseCLIArgs } from "./args";
import { LogLevel } from "./log";

describe("parseCLIArgs", () => {
Expand Down Expand Up @@ -155,23 +155,192 @@ describe("parseCLIArgs", () => {
expect(result.links).toEqual([{ url: "https://ci.example.com/run/123" }]);
});

it("throws on unknown flags (strict mode)", () => {
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
});

it("returns no warning when --name is used with update", () => {
const result = parseCLIArgs(["update", "--name", "Release 1.2.0"]);
expect(getCLIWarnings(result)).toEqual([]);
describe("--document / --document-file", () => {
it("parses --document with Title=content", () => {
const result = parseCLIArgs(["sync", "--document", "Changelog=# v1.0.0\n\n- first release"]);
expect(result.documents).toEqual([
{ title: "Changelog", source: { kind: "inline", content: "# v1.0.0\n\n- first release" } },
]);
});

it("parses multiple repeatable --document values", () => {
const result = parseCLIArgs([
"sync",
"--document",
"Changelog=# v1.0.0",
"--document=Deploy=Deployed to production.",
]);
expect(result.documents).toEqual([
{ title: "Changelog", source: { kind: "inline", content: "# v1.0.0" } },
{ title: "Deploy", source: { kind: "inline", content: "Deployed to production." } },
]);
});

it("preserves whitespace and equals signs in --document content", () => {
const result = parseCLIArgs(["sync", "--document", "Args=key1=value1\n key2 = value2"]);
expect(result.documents).toEqual([
{ title: "Args", source: { kind: "inline", content: "key1=value1\n key2 = value2" } },
]);
});

it("parses --document-file with Title=path", () => {
const result = parseCLIArgs(["sync", "--document-file", "Changelog=./CHANGELOG.md"]);
expect(result.documents).toEqual([{ title: "Changelog", source: { kind: "file", path: "./CHANGELOG.md" } }]);
});

it("trims title and path on --document-file", () => {
const result = parseCLIArgs(["sync", "--document-file", " Changelog = ./CHANGELOG.md "]);
expect(result.documents).toEqual([{ title: "Changelog", source: { kind: "file", path: "./CHANGELOG.md" } }]);
});

it("infers title from filename when --document-file is given a bare path", () => {
const result = parseCLIArgs(["sync", "--document-file", "./CHANGELOG.md"]);
expect(result.documents).toEqual([{ title: "CHANGELOG", source: { kind: "file", path: "./CHANGELOG.md" } }]);
});

it("infers title from basename when --document-file path has nested directories", () => {
const result = parseCLIArgs(["sync", "--document-file", "./docs/deploy-log.md"]);
expect(result.documents).toEqual([
{ title: "deploy-log", source: { kind: "file", path: "./docs/deploy-log.md" } },
]);
});

it("infers title from bare path with no extension", () => {
const result = parseCLIArgs(["sync", "--document-file", "./NOTES"]);
expect(result.documents).toEqual([{ title: "NOTES", source: { kind: "file", path: "./NOTES" } }]);
});

it("strips only the final extension when inferring title", () => {
const result = parseCLIArgs(["sync", "--document-file", "./release.notes.md"]);
expect(result.documents).toEqual([
{ title: "release.notes", source: { kind: "file", path: "./release.notes.md" } },
]);
});

it("throws when --document-file is bare '-' (stdin needs an explicit title)", () => {
expect(() => parseCLIArgs(["sync", "--document-file", "-"])).toThrow("Title=-");
});

it("combines --document and --document-file", () => {
const result = parseCLIArgs([
"sync",
"--document",
"Changelog=# v1.0.0",
"--document-file",
"Deploy log=./deploy.md",
]);
expect(result.documents).toEqual([
{ title: "Changelog", source: { kind: "inline", content: "# v1.0.0" } },
{ title: "Deploy log", source: { kind: "file", path: "./deploy.md" } },
]);
});

it("throws on --document without =", () => {
expect(() => parseCLIArgs(["sync", "--document", "no-separator"])).toThrow(
'Invalid --document value: "no-separator"',
);
});

it("throws on --document with empty title", () => {
expect(() => parseCLIArgs(["sync", "--document", "=content"])).toThrow("Document title must not be empty");
});

it("throws on --document with empty value", () => {
expect(() => parseCLIArgs(["sync", "--document", "Title="])).toThrow("Document value must not be empty");
});

it("throws on --document-file with empty path", () => {
expect(() => parseCLIArgs(["sync", "--document-file", "Title= "])).toThrow();
});
});

describe("--release-notes / --release-notes-file", () => {
it("parses inline --release-notes", () => {
const result = parseCLIArgs(["sync", "--release-notes", "## v1.0.0\n\nFirst release."]);
expect(result.releaseNotes).toEqual({
source: { kind: "inline", content: "## v1.0.0\n\nFirst release." },
});
});

it("parses --release-notes-file", () => {
const result = parseCLIArgs(["sync", "--release-notes-file", "./notes.md"]);
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } });
});

it("last-wins across multiple --release-notes occurrences", () => {
const result = parseCLIArgs([
"sync",
"--release-notes",
"first",
"--release-notes",
"second",
"--release-notes-file",
"./notes.md",
]);
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } });
});

it("throws on empty --release-notes-file path", () => {
expect(() => parseCLIArgs(["sync", "--release-notes-file", " "])).toThrow();
});

it("leaves releaseNotes undefined when no flag is passed", () => {
const result = parseCLIArgs(["sync"]);
expect(result.releaseNotes).toBeUndefined();
});

it("preserves argv order across --release-notes-file then --release-notes", () => {
// Regression: previously the parser grouped values by flag name, so a later inline note
// could be overridden by an earlier file note. Last on the command line should always win.
const result = parseCLIArgs([
"sync",
"--release-notes-file",
"./generated.md",
"--release-notes",
"manual override",
]);
expect(result.releaseNotes).toEqual({ source: { kind: "inline", content: "manual override" } });
});

it("preserves argv order across --release-notes then --release-notes-file", () => {
const result = parseCLIArgs(["sync", "--release-notes", "manual", "--release-notes-file", "./final.md"]);
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./final.md" } });
});
});

describe("argv order across --document / --document-file", () => {
it("preserves argv order so same-title last-wins works across flag types", () => {
// The API upserts documents by title with later entries winning. The CLI must therefore send
// documents in the order the user wrote them on the command line, not bucketed by flag type.
const result = parseCLIArgs([
"sync",
"--document-file",
"Changelog=./from-file.md",
"--document",
"Changelog=inline override",
]);
expect(result.documents).toEqual([
{ title: "Changelog", source: { kind: "file", path: "./from-file.md" } },
{ title: "Changelog", source: { kind: "inline", content: "inline override" } },
]);
});

it("interleaves inline and file documents in argv order", () => {
const result = parseCLIArgs([
"sync",
"--document",
"A=inline-a",
"--document-file",
"B=./b.md",
"--document",
"C=inline-c",
]);
expect(result.documents.map((d) => d.title)).toEqual(["A", "B", "C"]);
});
});

it("returns no warning when --name is used with complete", () => {
const result = parseCLIArgs(["complete", "--name", "Release 1.2.0"]);
expect(getCLIWarnings(result)).toEqual([]);
});

it("returns no warning when --name is used with sync", () => {
const result = parseCLIArgs(["sync", "--name", "Release 1.2.0"]);
expect(getCLIWarnings(result)).toEqual([]);
it("throws on unknown flags (strict mode)", () => {
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
});

it("defaults --timeout to 60 seconds", () => {
Expand Down
Loading
Loading