Skip to content

Commit cbc5a40

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

4 files changed

Lines changed: 418 additions & 22 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: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,173 @@ describe("parseCLIArgs", () => {
155155
expect(result.links).toEqual([{ url: "https://ci.example.com/run/123" }]);
156156
});
157157

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("combines --document and --document-file", () => {
197+
const result = parseCLIArgs([
198+
"sync",
199+
"--document",
200+
"Changelog=# v1.0.0",
201+
"--document-file",
202+
"Deploy log=./deploy.md",
203+
]);
204+
expect(result.documents).toEqual([
205+
{ title: "Changelog", source: { kind: "inline", content: "# v1.0.0" } },
206+
{ title: "Deploy log", source: { kind: "file", path: "./deploy.md" } },
207+
]);
208+
});
209+
210+
it("throws on --document without =", () => {
211+
expect(() => parseCLIArgs(["sync", "--document", "no-separator"])).toThrow(
212+
'Invalid --document value: "no-separator"',
213+
);
214+
});
215+
216+
it("throws on --document with empty title", () => {
217+
expect(() => parseCLIArgs(["sync", "--document", "=content"])).toThrow("Document title must not be empty");
218+
});
219+
220+
it("throws on --document with empty value", () => {
221+
expect(() => parseCLIArgs(["sync", "--document", "Title="])).toThrow("Document value must not be empty");
222+
});
223+
224+
it("throws on --document-file with empty path", () => {
225+
expect(() => parseCLIArgs(["sync", "--document-file", "Title= "])).toThrow();
226+
});
227+
});
228+
229+
describe("--release-notes / --release-notes-file", () => {
230+
it("parses inline --release-notes", () => {
231+
const result = parseCLIArgs(["sync", "--release-notes", "## v1.0.0\n\nFirst release."]);
232+
expect(result.releaseNotes).toEqual({
233+
source: { kind: "inline", content: "## v1.0.0\n\nFirst release." },
234+
});
235+
expect(result.releaseNotesOccurrences).toBe(1);
236+
});
237+
238+
it("parses --release-notes-file", () => {
239+
const result = parseCLIArgs(["sync", "--release-notes-file", "./notes.md"]);
240+
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } });
241+
});
242+
243+
it("last-wins across multiple --release-notes occurrences", () => {
244+
const result = parseCLIArgs([
245+
"sync",
246+
"--release-notes",
247+
"first",
248+
"--release-notes",
249+
"second",
250+
"--release-notes-file",
251+
"./notes.md",
252+
]);
253+
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./notes.md" } });
254+
expect(result.releaseNotesOccurrences).toBe(3);
255+
expect(getCLIWarnings(result)).toEqual([
256+
"--release-notes / --release-notes-file was specified 3 times; only the last value will be used.",
257+
]);
258+
});
259+
260+
it("emits no warning for a single --release-notes occurrence", () => {
261+
const result = parseCLIArgs(["sync", "--release-notes", "## v1.0.0"]);
262+
expect(getCLIWarnings(result)).toEqual([]);
263+
});
264+
265+
it("throws on empty --release-notes-file path", () => {
266+
expect(() => parseCLIArgs(["sync", "--release-notes-file", " "])).toThrow();
267+
});
268+
269+
it("leaves releaseNotes undefined when no flag is passed", () => {
270+
const result = parseCLIArgs(["sync"]);
271+
expect(result.releaseNotes).toBeUndefined();
272+
expect(result.releaseNotesOccurrences).toBe(0);
273+
});
274+
275+
it("preserves argv order across --release-notes-file then --release-notes", () => {
276+
// Regression: previously the parser grouped values by flag name, so a later inline note
277+
// could be overridden by an earlier file note. Last on the command line should always win.
278+
const result = parseCLIArgs([
279+
"sync",
280+
"--release-notes-file",
281+
"./generated.md",
282+
"--release-notes",
283+
"manual override",
284+
]);
285+
expect(result.releaseNotes).toEqual({ source: { kind: "inline", content: "manual override" } });
286+
});
287+
288+
it("preserves argv order across --release-notes then --release-notes-file", () => {
289+
const result = parseCLIArgs(["sync", "--release-notes", "manual", "--release-notes-file", "./final.md"]);
290+
expect(result.releaseNotes).toEqual({ source: { kind: "file", path: "./final.md" } });
291+
});
292+
});
293+
294+
describe("argv order across --document / --document-file", () => {
295+
it("preserves argv order so same-title last-wins works across flag types", () => {
296+
// The API upserts documents by title with later entries winning. The CLI must therefore send
297+
// documents in the order the user wrote them on the command line, not bucketed by flag type.
298+
const result = parseCLIArgs([
299+
"sync",
300+
"--document-file",
301+
"Changelog=./from-file.md",
302+
"--document",
303+
"Changelog=inline override",
304+
]);
305+
expect(result.documents).toEqual([
306+
{ title: "Changelog", source: { kind: "file", path: "./from-file.md" } },
307+
{ title: "Changelog", source: { kind: "inline", content: "inline override" } },
308+
]);
309+
});
310+
311+
it("interleaves inline and file documents in argv order", () => {
312+
const result = parseCLIArgs([
313+
"sync",
314+
"--document",
315+
"A=inline-a",
316+
"--document-file",
317+
"B=./b.md",
318+
"--document",
319+
"C=inline-c",
320+
]);
321+
expect(result.documents.map((d) => d.title)).toEqual(["A", "B", "C"]);
322+
});
323+
});
324+
158325
it("throws on unknown flags (strict mode)", () => {
159326
expect(() => parseCLIArgs(["--unknown-flag"])).toThrow();
160327
});

0 commit comments

Comments
 (0)