Skip to content

Commit cdf76c2

Browse files
committed
Allow passing document and release-notes flags
1 parent 1ae492d commit cdf76c2

4 files changed

Lines changed: 441 additions & 44 deletions

File tree

README.md

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -149,18 +149,22 @@ linear-release update --stage="in review" --name="Release 1.2.0"
149149

150150
### CLI Options
151151

152-
| Option | Commands | Description |
153-
| ------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154-
| `--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. |
155-
| `--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. |
156-
| `--stage` | `update` | Target deployment stage (required for `update`) |
157-
| `--include-paths` | `sync` | Filter commits by changed file paths |
158-
| `--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. |
159-
| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `<base-ref>..HEAD`. |
160-
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
161-
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
162-
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
163-
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |
152+
| Option | Commands | Description |
153+
| ---------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
154+
| `--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. |
155+
| `--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. |
156+
| `--stage` | `update` | Target deployment stage (required for `update`) |
157+
| `--include-paths` | `sync` | Filter commits by changed file paths |
158+
| `--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. |
159+
| `--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. |
160+
| `--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. |
161+
| `--release-notes` | `sync`, `complete`, `update` | Set the release notes for this release. Inline markdown. If combined with `--release-notes-file`, the last flag wins. |
162+
| `--release-notes-file` | `sync`, `complete`, `update` | Same as `--release-notes` but reads from a file. Use `-` for stdin. |
163+
| `--base-ref` | `sync` | Override the scan base. Exclusive: scans `<base-ref>..HEAD`. |
164+
| `--json` | `sync`, `complete`, `update` | Output result as JSON on stdout. Logs are emitted as JSON Lines (one object per line) on stderr. |
165+
| `--quiet` | `sync`, `complete`, `update` | Suppress info-level output. Warnings and errors are still printed. |
166+
| `--verbose` | `sync`, `complete`, `update` | Print detailed progress including debug diagnostics |
167+
| `--timeout` | `sync`, `complete`, `update` | Max duration in seconds before aborting (default: 60) |
164168

165169
### Command Targeting
166170

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

232236
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:`.
233237

238+
### Documents and release notes
239+
240+
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.
241+
242+
```bash
243+
# Release notes from a generated changelog
244+
linear-release sync --release-notes-file ./CHANGELOG.md
245+
246+
# Plus extra documents (deploy log, runbook, etc.)
247+
linear-release sync \
248+
--release-notes-file ./CHANGELOG.md \
249+
--document-file "Deploy log=./deploy.log" \
250+
--document-file "Runbook=./runbook.md"
251+
252+
# Stdin works on both flags — useful when piping from another command
253+
git log v1.0.0..HEAD --format="- %s" | linear-release sync --release-notes-file -
254+
255+
# Inline (single-line content only — see "Multi-line content" below)
256+
linear-release sync --document "Deploy log=Deployed to production at $(date -u +%FT%TZ)"
257+
```
258+
259+
> **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).
260+
234261
## How It Works
235262

236263
1. **Fetches the latest release** from your Linear pipeline to determine the commit range

src/args.test.ts

Lines changed: 185 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from "vitest";
2-
import { getCLIWarnings, parseCLIArgs } from "./args";
2+
import { parseCLIArgs } from "./args";
33
import { LogLevel } from "./log";
44

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

158-
it("throws on unknown flags (strict mode)", () => {
159-
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
160-
});
161-
162-
it("returns no warning when --name is used with update", () => {
163-
const result = parseCLIArgs(["update", "--name", "Release 1.2.0"]);
164-
expect(getCLIWarnings(result)).toEqual([]);
158+
describe("--document / --document-file", () => {
159+
it("parses --document with Title=content", () => {
160+
const result = parseCLIArgs(["sync", "--document", "Changelog=# v1.0.0\n\n- first release"]);
161+
expect(result.documents).toEqual([
162+
{ title: "Changelog", source: { kind: "inline", content: "# v1.0.0\n\n- first release" } },
163+
]);
164+
});
165+
166+
it("parses multiple repeatable --document values", () => {
167+
const result = parseCLIArgs([
168+
"sync",
169+
"--document",
170+
"Changelog=# v1.0.0",
171+
"--document=Deploy=Deployed to production.",
172+
]);
173+
expect(result.documents).toEqual([
174+
{ title: "Changelog", source: { kind: "inline", content: "# v1.0.0" } },
175+
{ title: "Deploy", source: { kind: "inline", content: "Deployed to production." } },
176+
]);
177+
});
178+
179+
it("preserves whitespace and equals signs in --document content", () => {
180+
const result = parseCLIArgs(["sync", "--document", "Args=key1=value1\n key2 = value2"]);
181+
expect(result.documents).toEqual([
182+
{ title: "Args", source: { kind: "inline", content: "key1=value1\n key2 = value2" } },
183+
]);
184+
});
185+
186+
it("parses --document-file with Title=path", () => {
187+
const result = parseCLIArgs(["sync", "--document-file", "Changelog=./CHANGELOG.md"]);
188+
expect(result.documents).toEqual([{ title: "Changelog", source: { kind: "file", path: "./CHANGELOG.md" } }]);
189+
});
190+
191+
it("trims title and path on --document-file", () => {
192+
const result = parseCLIArgs(["sync", "--document-file", " Changelog = ./CHANGELOG.md "]);
193+
expect(result.documents).toEqual([{ title: "Changelog", source: { kind: "file", path: "./CHANGELOG.md" } }]);
194+
});
195+
196+
it("infers title from filename when --document-file is given a bare path", () => {
197+
const result = parseCLIArgs(["sync", "--document-file", "./CHANGELOG.md"]);
198+
expect(result.documents).toEqual([{ title: "CHANGELOG", source: { kind: "file", path: "./CHANGELOG.md" } }]);
199+
});
200+
201+
it("infers title from basename when --document-file path has nested directories", () => {
202+
const result = parseCLIArgs(["sync", "--document-file", "./docs/deploy-log.md"]);
203+
expect(result.documents).toEqual([
204+
{ title: "deploy-log", source: { kind: "file", path: "./docs/deploy-log.md" } },
205+
]);
206+
});
207+
208+
it("infers title from bare path with no extension", () => {
209+
const result = parseCLIArgs(["sync", "--document-file", "./NOTES"]);
210+
expect(result.documents).toEqual([{ title: "NOTES", source: { kind: "file", path: "./NOTES" } }]);
211+
});
212+
213+
it("strips only the final extension when inferring title", () => {
214+
const result = parseCLIArgs(["sync", "--document-file", "./release.notes.md"]);
215+
expect(result.documents).toEqual([
216+
{ title: "release.notes", source: { kind: "file", path: "./release.notes.md" } },
217+
]);
218+
});
219+
220+
it("throws when --document-file is bare '-' (stdin needs an explicit title)", () => {
221+
expect(() => parseCLIArgs(["sync", "--document-file", "-"])).toThrow("Title=-");
222+
});
223+
224+
it("combines --document and --document-file", () => {
225+
const result = parseCLIArgs([
226+
"sync",
227+
"--document",
228+
"Changelog=# v1.0.0",
229+
"--document-file",
230+
"Deploy log=./deploy.md",
231+
]);
232+
expect(result.documents).toEqual([
233+
{ title: "Changelog", source: { kind: "inline", content: "# v1.0.0" } },
234+
{ title: "Deploy log", source: { kind: "file", path: "./deploy.md" } },
235+
]);
236+
});
237+
238+
it("throws on --document without =", () => {
239+
expect(() => parseCLIArgs(["sync", "--document", "no-separator"])).toThrow(
240+
'Invalid --document value: "no-separator"',
241+
);
242+
});
243+
244+
it("throws on --document with empty title", () => {
245+
expect(() => parseCLIArgs(["sync", "--document", "=content"])).toThrow("Document title must not be empty");
246+
});
247+
248+
it("throws on --document with empty value", () => {
249+
expect(() => parseCLIArgs(["sync", "--document", "Title="])).toThrow("Document value must not be empty");
250+
});
251+
252+
it("throws on --document-file with empty path", () => {
253+
expect(() => parseCLIArgs(["sync", "--document-file", "Title= "])).toThrow();
254+
});
255+
});
256+
257+
describe("--release-notes / --release-notes-file", () => {
258+
it("parses inline --release-notes", () => {
259+
const result = parseCLIArgs(["sync", "--release-notes", "## v1.0.0\n\nFirst release."]);
260+
expect(result.releaseNotes).toEqual({
261+
source: { kind: "inline", content: "## v1.0.0\n\nFirst release." },
262+
});
263+
});
264+
265+
it("parses --release-notes-file", () => {
266+
const result = parseCLIArgs(["sync", "--release-notes-file", "./notes.md"]);
267+
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } });
268+
});
269+
270+
it("last-wins across multiple --release-notes occurrences", () => {
271+
const result = parseCLIArgs([
272+
"sync",
273+
"--release-notes",
274+
"first",
275+
"--release-notes",
276+
"second",
277+
"--release-notes-file",
278+
"./notes.md",
279+
]);
280+
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } });
281+
});
282+
283+
it("throws on empty --release-notes-file path", () => {
284+
expect(() => parseCLIArgs(["sync", "--release-notes-file", " "])).toThrow();
285+
});
286+
287+
it("leaves releaseNotes undefined when no flag is passed", () => {
288+
const result = parseCLIArgs(["sync"]);
289+
expect(result.releaseNotes).toBeUndefined();
290+
});
291+
292+
it("preserves argv order across --release-notes-file then --release-notes", () => {
293+
// Regression: previously the parser grouped values by flag name, so a later inline note
294+
// could be overridden by an earlier file note. Last on the command line should always win.
295+
const result = parseCLIArgs([
296+
"sync",
297+
"--release-notes-file",
298+
"./generated.md",
299+
"--release-notes",
300+
"manual override",
301+
]);
302+
expect(result.releaseNotes).toEqual({ source: { kind: "inline", content: "manual override" } });
303+
});
304+
305+
it("preserves argv order across --release-notes then --release-notes-file", () => {
306+
const result = parseCLIArgs(["sync", "--release-notes", "manual", "--release-notes-file", "./final.md"]);
307+
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./final.md" } });
308+
});
309+
});
310+
311+
describe("argv order across --document / --document-file", () => {
312+
it("preserves argv order so same-title last-wins works across flag types", () => {
313+
// The API upserts documents by title with later entries winning. The CLI must therefore send
314+
// documents in the order the user wrote them on the command line, not bucketed by flag type.
315+
const result = parseCLIArgs([
316+
"sync",
317+
"--document-file",
318+
"Changelog=./from-file.md",
319+
"--document",
320+
"Changelog=inline override",
321+
]);
322+
expect(result.documents).toEqual([
323+
{ title: "Changelog", source: { kind: "file", path: "./from-file.md" } },
324+
{ title: "Changelog", source: { kind: "inline", content: "inline override" } },
325+
]);
326+
});
327+
328+
it("interleaves inline and file documents in argv order", () => {
329+
const result = parseCLIArgs([
330+
"sync",
331+
"--document",
332+
"A=inline-a",
333+
"--document-file",
334+
"B=./b.md",
335+
"--document",
336+
"C=inline-c",
337+
]);
338+
expect(result.documents.map((d) => d.title)).toEqual(["A", "B", "C"]);
339+
});
165340
});
166341

167-
it("returns no warning when --name is used with complete", () => {
168-
const result = parseCLIArgs(["complete", "--name", "Release 1.2.0"]);
169-
expect(getCLIWarnings(result)).toEqual([]);
170-
});
171-
172-
it("returns no warning when --name is used with sync", () => {
173-
const result = parseCLIArgs(["sync", "--name", "Release 1.2.0"]);
174-
expect(getCLIWarnings(result)).toEqual([]);
342+
it("throws on unknown flags (strict mode)", () => {
343+
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
175344
});
176345

177346
it("defaults --timeout to 60 seconds", () => {

0 commit comments

Comments
 (0)