@columenlabs/lumen-ship 0.0.0-stage → 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +108 -2
- package/bin/lumen-ship +22 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/releases.d.ts +29 -0
- package/dist/releases.js +17 -0
- package/package.json +42 -4
- package/scripts/check-kit-neutral.sh +18 -0
- package/scripts/check-magic-numbers.sh +19 -0
- package/scripts/check-settings-copy.sh +17 -0
- package/scripts/check-suppressions.sh +13 -0
- package/scripts/release.sh +227 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Columen Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Lumen
|
|
2
|
+
Copyright (c) 2026 Columen Labs. Licensed under the MIT License (see LICENSE).
|
|
3
|
+
|
|
4
|
+
Lumen draws price and market charts with TradingView Lightweight Charts™
|
|
5
|
+
(https://github.com/tradingview/lightweight-charts), Copyright (c) 2025
|
|
6
|
+
TradingView, Inc., licensed under the Apache License 2.0. Lumen turns off the
|
|
7
|
+
chart's own logo, so per that library's NOTICE, an app that shows these charts
|
|
8
|
+
should link to https://www.tradingview.com/ somewhere a user can find it (an
|
|
9
|
+
About page or footer is enough).
|
|
10
|
+
|
|
11
|
+
The components under packages/ui/src/ui are based on shadcn/ui
|
|
12
|
+
(https://ui.shadcn.com), MIT License, Copyright (c) 2023 shadcn.
|
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @columenlabs/lumen-ship
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Lumen's shipping kit: one release command that writes each release's notes with Claude, the changelog What's new reads, and the lint guards every Lumen app runs. Bash plus a small TypeScript reader, no framework.
|
|
4
|
+
|
|
5
|
+
- Docs: https://lumen.columenlabs.com/docs/
|
|
6
|
+
- For agents: https://lumen.columenlabs.com/llms.txt
|
|
7
|
+
- Source: https://github.com/columenlabs/lumen (`packages/ship`). MIT.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add -d @columenlabs/lumen-ship
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Pin it to the same exact version as the other Lumen packages. The command needs `bash`, `git`, `gh` (with REST access to the repo), `jq` and `curl`.
|
|
16
|
+
|
|
17
|
+
## Releases
|
|
18
|
+
|
|
19
|
+
`lumen-ship release` cuts a repo's GitHub releases. Run it from the repo root, on each merge to `main` (a CI job) or by hand.
|
|
20
|
+
|
|
21
|
+
| Command | What it does |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `lumen-ship release` | One patch release at `main` once `cadence.every` merges landed since the newest release tag, or on the first merge `cadence.days` days after it. `FORCE=1` cuts it now |
|
|
24
|
+
| `lumen-ship release minor` | Tags `main` as the next minor. Its notes are the hand-written `docs/releases/v<major>.<minor>.0.md`, which must be on `main` |
|
|
25
|
+
| `lumen-ship release notes` | Sets the newest minor's notes from its file again |
|
|
26
|
+
| `lumen-ship release tag v1.2.3` | Makes the release of a tag that already exists (for a publish job that tags first). `REDO=1` writes its notes again |
|
|
27
|
+
|
|
28
|
+
`DRY_RUN=1` prints what it would do and creates nothing.
|
|
29
|
+
|
|
30
|
+
### Notes
|
|
31
|
+
|
|
32
|
+
Claude writes each release's notes from the merged pull requests since the tag before (commits stand in where there are none), from your prompt. A reply is published only when whole: anything cut short, refused or failed is asked again, at most 3 times, then the release fails. Without `ANTHROPIC_API_KEY` the notes are GitHub's own list of pull requests.
|
|
33
|
+
|
|
34
|
+
Set `LUMEN_FORBIDDEN_NAMES` (a regex alternation, `A|B`) in a public repo: notes that name one are asked again and never published, and the pull request list shows numbers only.
|
|
35
|
+
|
|
36
|
+
### Changelog for What's new
|
|
37
|
+
|
|
38
|
+
With `changelog.path` set, each release's Key takeaways are also committed to `main` as JSON, newest first, and the tag goes on that commit, so the release deploys its own notes. Each takeaway bullet opens with tags: type tags (`[Feature]`, `[Improvement]`, `[Fix]`, `[Performance]`, `[Security]`) become its chips, and tags listed in `changelog.apps` become its `apps`. Ask for them in your prompt:
|
|
39
|
+
|
|
40
|
+
```md
|
|
41
|
+
- [Fix] [Console] **Exports finish**: ...
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
[{ "tag": "v0.3.2", "published_at": "2026-10-24T18:00:00Z",
|
|
46
|
+
"takeaways": [{ "text": "**Exports finish**: ...", "types": ["fix"], "apps": ["console"] }] }]
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Serve it to `WhatsNew` (`LumenShell`'s `releases` prop) with the reader:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { RELEASES_HEADERS, listReleases, type Release } from '@columenlabs/lumen-ship/releases';
|
|
53
|
+
import changelog from './changelog.json';
|
|
54
|
+
|
|
55
|
+
app.get('/api/releases', (c) =>
|
|
56
|
+
c.json(
|
|
57
|
+
{ releases: listReleases(changelog as Release[], {
|
|
58
|
+
limit: c.req.query('limit'),
|
|
59
|
+
before: c.req.query('before'),
|
|
60
|
+
canSee: (apps) => apps.some((a) => mayOpen(c, a)), // optional
|
|
61
|
+
}) },
|
|
62
|
+
200,
|
|
63
|
+
RELEASES_HEADERS,
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`listReleases` pages newest first (`limit` 1 to 50, 20 by default; `before` an ISO time) and, with `canSee`, keeps a takeaway about some apps only for people who may open one of them.
|
|
69
|
+
|
|
70
|
+
### `lumen-ship.json`
|
|
71
|
+
|
|
72
|
+
At the repo root (or the path in `LUMEN_SHIP_CONFIG`):
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{
|
|
76
|
+
"repo": "owner/name",
|
|
77
|
+
"notes": { "prompt": ".github/release-prompt.md", "model": "claude-sonnet-5-5", "maxTokens": 16000 },
|
|
78
|
+
"changelog": { "path": "src/changelog.json", "apps": { "console": "console", "api": "api" } },
|
|
79
|
+
"tags": "v0.*",
|
|
80
|
+
"promote": "release",
|
|
81
|
+
"cadence": { "every": 10, "days": 7 }
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Key | |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `repo` | The GitHub repo the releases go on. Required |
|
|
88
|
+
| `notes.prompt` | A file with the prompt; `{tag}` becomes the release's tag. Required |
|
|
89
|
+
| `notes.model`, `notes.maxTokens` | The model and its output limit (the model thinks first, so keep it large) |
|
|
90
|
+
| `changelog.path` | Where the What's new JSON lives on `main`; left out, no changelog |
|
|
91
|
+
| `changelog.apps` | Tag (lower case) → app id, for takeaways about one app |
|
|
92
|
+
| `tags` | The release tags as a git glob, `v[0-9]*` by default; narrow it when older tags of another scheme sort higher |
|
|
93
|
+
| `promote` | A branch fast-forwarded to each new release (a production deploy branch, say) |
|
|
94
|
+
| `cadence.every`, `cadence.days` | When a patch is due. `RELEASE_EVERY` overrides `every` |
|
|
95
|
+
|
|
96
|
+
## Lint guards
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
lumen-ship check kit-neutral [paths] # no name from LUMEN_FORBIDDEN_NAMES (default: src package.json README.md)
|
|
100
|
+
lumen-ship check suppressions # a useExhaustiveDependencies suppression needs a reason of 3+ words
|
|
101
|
+
lumen-ship check settings-copy # a Settings section description is one plain line of 64 characters or less
|
|
102
|
+
lumen-ship check magic-numbers # hard-coded px/rem class values may only go down
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Run each from a package folder in its `lint` script. `magic-numbers` reads the repo's `scripts/magic-numbers.baseline` (or `LUMEN_SHIP_BASELINE`): one `<folder> <count>` a line, lowered in the change that removes one.
|
|
106
|
+
|
|
107
|
+
## Versions
|
|
108
|
+
|
|
109
|
+
0.x: any minor may break things until 1.0. Every merge to Lumen's `main` publishes the next patch of every Lumen package; see the [releases](https://github.com/columenlabs/lumen/releases).
|
package/bin/lumen-ship
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# lumen-ship: releases and lint guards shared by every app. Run from the repo root.
|
|
3
|
+
# lumen-ship release [patch | minor | notes | tag v<version>] see scripts/release.sh
|
|
4
|
+
# lumen-ship check suppressions | settings-copy | magic-numbers | kit-neutral [paths]
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
here=$(cd "$(dirname "$(readlink -f "$0")")/../scripts" && pwd)
|
|
7
|
+
cmd=${1:-}
|
|
8
|
+
shift || true
|
|
9
|
+
case "$cmd" in
|
|
10
|
+
release) exec "$here/release.sh" "$@" ;;
|
|
11
|
+
check)
|
|
12
|
+
name=${1:-}
|
|
13
|
+
shift || true
|
|
14
|
+
case "$name" in
|
|
15
|
+
suppressions | settings-copy | magic-numbers | kit-neutral) exec "$here/check-$name.sh" "$@" ;;
|
|
16
|
+
esac
|
|
17
|
+
echo "usage: lumen-ship check suppressions | settings-copy | magic-numbers | kit-neutral" >&2
|
|
18
|
+
exit 1
|
|
19
|
+
;;
|
|
20
|
+
esac
|
|
21
|
+
echo "usage: lumen-ship release [patch | minor | notes | tag v<version>] | lumen-ship check <name>" >&2
|
|
22
|
+
exit 1
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './releases.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './releases.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A takeaway's coloured chips in What's new, one or more. */
|
|
2
|
+
export type ReleaseType = 'feature' | 'improvement' | 'fix' | 'performance' | 'security';
|
|
3
|
+
export interface Takeaway {
|
|
4
|
+
/** One Key takeaways bullet, markdown, its tags taken off. */
|
|
5
|
+
text: string;
|
|
6
|
+
types: ReleaseType[];
|
|
7
|
+
/** The apps it is about, from `changelog.apps` in lumen-ship.json; left out: everyone's. */
|
|
8
|
+
apps?: string[];
|
|
9
|
+
}
|
|
10
|
+
export interface Release {
|
|
11
|
+
tag: string;
|
|
12
|
+
/** ISO time; pages are cut by it, newest first. */
|
|
13
|
+
published_at: string;
|
|
14
|
+
takeaways: Takeaway[];
|
|
15
|
+
}
|
|
16
|
+
export interface ListReleasesOptions {
|
|
17
|
+
/** How many, 1 to 50; 20 when left out or not a number. */
|
|
18
|
+
limit?: number | string | null;
|
|
19
|
+
/** Only releases published before this ISO time (the last one of the page before). */
|
|
20
|
+
before?: string | null;
|
|
21
|
+
/** Keeps a takeaway about some apps only for people who may open one of them. */
|
|
22
|
+
canSee?: (apps: string[]) => boolean;
|
|
23
|
+
}
|
|
24
|
+
/** One page of releases, newest first. A release left with no takeaways keeps its tag. */
|
|
25
|
+
export declare function listReleases(releases: Release[], options?: ListReleasesOptions): Release[];
|
|
26
|
+
/** The response headers What's new expects: private, and cached five minutes. */
|
|
27
|
+
export declare const RELEASES_HEADERS: {
|
|
28
|
+
readonly 'cache-control': 'private, max-age=300';
|
|
29
|
+
};
|
package/dist/releases.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// What's new: the releases a repo's `lumen-ship release` wrote into its changelog JSON, served the
|
|
2
|
+
// way @columenlabs/lumen-ui's WhatsNew reads them (`GET ?limit=&before=` answering `{releases}`).
|
|
3
|
+
// No framework: an app mounts `listReleases` on whatever router it runs.
|
|
4
|
+
/** One page of releases, newest first. A release left with no takeaways keeps its tag. */
|
|
5
|
+
export function listReleases(releases, options = {}) {
|
|
6
|
+
const limit = Math.min(Math.max(Number(options.limit) || 20, 1), 50);
|
|
7
|
+
const { before, canSee } = options;
|
|
8
|
+
const list = (before ? releases.filter((r) => r.published_at < before) : releases).slice(0, limit);
|
|
9
|
+
if (!canSee)
|
|
10
|
+
return list;
|
|
11
|
+
return list.map((r) => ({
|
|
12
|
+
...r,
|
|
13
|
+
takeaways: r.takeaways.filter((t) => !t.apps?.length || canSee(t.apps)),
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
/** The response headers What's new expects: private, and cached five minutes. */
|
|
17
|
+
export const RELEASES_HEADERS = { 'cache-control': 'private, max-age=300' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@columenlabs/lumen-ship",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.30",
|
|
4
|
+
"description": "Lumen's shipping kit: release notes, the What's new changelog and lint guards.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./releases": {
|
|
15
|
+
"types": "./dist/releases.d.ts",
|
|
16
|
+
"default": "./dist/releases.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"lumen-ship": "bin/lumen-ship"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"NOTICE",
|
|
26
|
+
"bin",
|
|
27
|
+
"scripts"
|
|
28
|
+
],
|
|
29
|
+
"sideEffects": [
|
|
30
|
+
"**/*.css"
|
|
31
|
+
],
|
|
32
|
+
"homepage": "https://github.com/columenlabs/lumen",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/columenlabs/lumen.git",
|
|
36
|
+
"directory": "packages/ship"
|
|
37
|
+
},
|
|
38
|
+
"bugs": "https://github.com/columenlabs/lumen/issues",
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {}
|
|
44
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# A shared package names no product: no app's names in code or copy, and no import of an app's
|
|
3
|
+
# own packages. An app passes its names through `configureLumen` and its data through
|
|
4
|
+
# `registerDomains`. The names to refuse are kept outside the repo, in the environment variable
|
|
5
|
+
# LUMEN_FORBIDDEN_NAMES (a regex alternation, `A|B|C`); CI reads it from the repository variable
|
|
6
|
+
# of the same name, and the check is skipped where it is not set. Comments count too.
|
|
7
|
+
# Run from a package folder by its lint script; pass other folders to check those instead.
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
names=${LUMEN_FORBIDDEN_NAMES:-}
|
|
10
|
+
[ -n "$names" ] || exit 0
|
|
11
|
+
targets=("$@")
|
|
12
|
+
[ ${#targets[@]} -gt 0 ] || targets=(src package.json README.md)
|
|
13
|
+
bad=$(grep -rnE "\b($names)\b" "${targets[@]}" || true)
|
|
14
|
+
if [ -n "$bad" ]; then
|
|
15
|
+
echo "The kit names no product: pass names through configureLumen, data through registerDomains:" >&2
|
|
16
|
+
echo "$bad" >&2
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# No new hard-coded lengths: a pixel or rem value written into a class
|
|
3
|
+
# (`left-[17px]`, `text-[11px]`) is a band-aid sized for one screen, and the next layout change
|
|
4
|
+
# breaks it. Use a token, a CSS variable or a Tailwind scale step instead.
|
|
5
|
+
# The count may only fall: the repo's scripts/magic-numbers.baseline (or LUMEN_SHIP_BASELINE) holds
|
|
6
|
+
# each package's count today, one `<folder> <count>` a line; lower it in the change that removes one.
|
|
7
|
+
# Run from a package folder by its lint script.
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
baseline=${LUMEN_SHIP_BASELINE:-$(git rev-parse --show-toplevel)/scripts/magic-numbers.baseline}
|
|
10
|
+
pkg=$(basename "$PWD")
|
|
11
|
+
# `|| true`: a package with none (grep finds nothing) passes.
|
|
12
|
+
n=$({ grep -rEoh --include=*.tsx --include=*.ts -- '-?\b[a-z-]+-\[-?[0-9.]+(px|rem)\]' src || true; } | wc -l | tr -d ' ')
|
|
13
|
+
base=$( [ -f "$baseline" ] && awk -v p="$pkg" '$1 == p { print $2 }' "$baseline" || true)
|
|
14
|
+
base=${base:-0}
|
|
15
|
+
if [ "$n" -gt "$base" ]; then
|
|
16
|
+
echo "$pkg: $n hard-coded px/rem class values, baseline $base. Use a token or CSS variable:" >&2
|
|
17
|
+
grep -rnE --include=*.tsx --include=*.ts -- '-?\b[a-z-]+-\[-?[0-9.]+(px|rem)\]' src >&2
|
|
18
|
+
exit 1
|
|
19
|
+
fi
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# A Settings section's description is one line, never wrapped: a plain string
|
|
3
|
+
# of at most 64 characters, which fits the section header on desktop (the header truncates it on a
|
|
4
|
+
# phone). Run from a package folder by its lint script.
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
MAX=64
|
|
7
|
+
bad=$({ grep -rlE "SettingsSectionDef" src --include='*.tsx' || true; } | xargs -r perl -0777 -ne '
|
|
8
|
+
while (/icon:\s*\w+,\s*(?:sub:\s*true,\s*)?description:\s*(\x27([^\x27]*)\x27|"([^"]*)"|[^\x27"\s][^,\n]*)/g) {
|
|
9
|
+
my $text = defined $2 ? $2 : $3;
|
|
10
|
+
if (!defined $text) { print "$ARGV: description is not a plain string: $1\n"; next; }
|
|
11
|
+
print "$ARGV: ", length($text), " characters (max '"$MAX"'): $text\n" if length($text) > '"$MAX"';
|
|
12
|
+
}')
|
|
13
|
+
if [ -n "$bad" ]; then
|
|
14
|
+
echo "Settings descriptions must fit on one line:" >&2
|
|
15
|
+
echo "$bad" >&2
|
|
16
|
+
exit 1
|
|
17
|
+
fi
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Switching off React's "missing dependency" lint says why, in at least three words: a silenced
|
|
3
|
+
# dependency is how an effect loops or reads stale state, so each one carries its reason for the
|
|
4
|
+
# next reader.
|
|
5
|
+
# Run from a package folder by its lint script.
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
bad=$(grep -rnE "biome-ignore lint/correctness/useExhaustiveDependencies" src \
|
|
8
|
+
| awk -F'useExhaustiveDependencies:?' '{ n = split($2, w, " "); if (n < 3) { split($0, a, ":"); print a[1] ":" a[2] } }' || true)
|
|
9
|
+
if [ -n "$bad" ]; then
|
|
10
|
+
echo "A useExhaustiveDependencies suppression needs its reason (three words or more):" >&2
|
|
11
|
+
echo "$bad" >&2
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Cut a repo's GitHub releases with notes Claude writes from the merged pull requests, through the
|
|
3
|
+
# GitHub REST API. Run from the repo's root, which holds lumen-ship.json (see the package README).
|
|
4
|
+
# lumen-ship release one patch release at main once `cadence.every` first-parent merges
|
|
5
|
+
# landed since the latest release tag (`tags`, default v[0-9]*),
|
|
6
|
+
# or on the first merge `cadence.days` days after it; FORCE=1
|
|
7
|
+
# cuts it now
|
|
8
|
+
# lumen-ship release minor tag main as the next minor; its notes are the hand-written
|
|
9
|
+
# docs/releases/v<major>.<minor>.0.md (required)
|
|
10
|
+
# lumen-ship release notes set the latest minor release's notes from its file again
|
|
11
|
+
# lumen-ship release tag v1.2.3 make the release of a tag that already exists (a publish
|
|
12
|
+
# workflow tagged it); REDO=1 writes its notes again
|
|
13
|
+
# DRY_RUN=1 lumen-ship release print what it would do, create nothing
|
|
14
|
+
# With `changelog.path` set, each release's Key takeaways are also committed to main as JSON
|
|
15
|
+
# (What's new reads them, @columenlabs/lumen-ship/releases) and the tag goes on that commit, so
|
|
16
|
+
# the release deploys its own notes. With `promote` set, that branch is fast-forwarded to the
|
|
17
|
+
# release. Needs gh with REST access to the repo, jq and curl; the notes need ANTHROPIC_API_KEY,
|
|
18
|
+
# and without one they are GitHub's own list of pull requests. Notes that name anything in
|
|
19
|
+
# LUMEN_FORBIDDEN_NAMES are asked again, then the release fails (for public repos).
|
|
20
|
+
set -euo pipefail
|
|
21
|
+
CONFIG=${LUMEN_SHIP_CONFIG:-lumen-ship.json}
|
|
22
|
+
[ -f "$CONFIG" ] || { echo "no $CONFIG here: run from the repo root (see lumen-ship's README)" >&2; exit 1; }
|
|
23
|
+
cfg() { jq -r "$1 // empty" "$CONFIG"; }
|
|
24
|
+
REPO=$(cfg .repo)
|
|
25
|
+
[ -n "$REPO" ] || { echo "$CONFIG needs repo (owner/name)" >&2; exit 1; }
|
|
26
|
+
PROMPT_FILE=$(cfg .notes.prompt)
|
|
27
|
+
[ -n "$PROMPT_FILE" ] && [ -f "$PROMPT_FILE" ] || { echo "$CONFIG notes.prompt must name a file" >&2; exit 1; }
|
|
28
|
+
MODEL=$(cfg .notes.model); MODEL=${MODEL:-claude-sonnet-5-5}
|
|
29
|
+
MAX_TOKENS=$(cfg .notes.maxTokens); MAX_TOKENS=${MAX_TOKENS:-16000}
|
|
30
|
+
CHANGELOG=$(cfg .changelog.path)
|
|
31
|
+
APPS=$(jq -c '.changelog.apps // {}' "$CONFIG")
|
|
32
|
+
PROMOTE=$(cfg .promote)
|
|
33
|
+
EVERY=${RELEASE_EVERY:-$(cfg .cadence.every)}; EVERY=${EVERY:-10}
|
|
34
|
+
DAYS=$(cfg .cadence.days); DAYS=${DAYS:-7}
|
|
35
|
+
# The release tags, as a git glob: narrow it when the repo keeps tags of an older scheme.
|
|
36
|
+
TAGS=$(cfg .tags); TAGS=${TAGS:-v[0-9]*}
|
|
37
|
+
DRY_RUN=${DRY_RUN:-0}
|
|
38
|
+
FORCE=${FORCE:-0}
|
|
39
|
+
REDO=${REDO:-0}
|
|
40
|
+
MODE=${1:-patch}
|
|
41
|
+
TMP=${RUNNER_TEMP:-/tmp}
|
|
42
|
+
git fetch -q origin main --tags
|
|
43
|
+
|
|
44
|
+
named() { # text: the forbidden names it carries, if any
|
|
45
|
+
[ -n "${LUMEN_FORBIDDEN_NAMES:-}" ] || return 0
|
|
46
|
+
grep -oiE "\b(${LUMEN_FORBIDDEN_NAMES})\b" <<< "$1" | sort -u | tr '\n' ' ' || true
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
notes() { # tag sha previous-tag: Claude's notes from the pull requests (or commits) between them
|
|
50
|
+
local range=${3:+$3..}$2
|
|
51
|
+
prs=$(git log --first-parent --format=%s "$range" | grep -oE '\(#[0-9]+\)$' | tr -dc '0-9\n' || true)
|
|
52
|
+
pulls=$(for n in $prs; do
|
|
53
|
+
gh api "repos/$REPO/pulls/$n" --jq '{number, title, body, login: .user.login}' \
|
|
54
|
+
|| { echo "could not read PR #$n" >&2; return 1; }
|
|
55
|
+
done) || return 1
|
|
56
|
+
if [ -n "$pulls" ]; then
|
|
57
|
+
input=$(jq -r '"## #\(.number) \(.title)\n\((.body // "") | .[0:1500])\n"' <<< "$pulls")
|
|
58
|
+
if [ -n "${LUMEN_FORBIDDEN_NAMES:-}" ]; then # titles are checked by no one: numbers only
|
|
59
|
+
list=$(jq -r '"- #\(.number) by @\(.login)"' <<< "$pulls")
|
|
60
|
+
else
|
|
61
|
+
list=$(jq -r '"- #\(.number) \(.title) (@\(.login))"' <<< "$pulls")
|
|
62
|
+
fi
|
|
63
|
+
count="Pull requests ($(echo "$prs" | wc -w | tr -d ' '))"
|
|
64
|
+
else # commits straight to main, like a first release: their messages stand in
|
|
65
|
+
input=$(git log --first-parent --format='## %s%n%b' "$range" | cut -c1-1500)
|
|
66
|
+
list=$(git log --first-parent --format='- %h' "$range")
|
|
67
|
+
count="Commits ($(echo "$list" | wc -l | tr -d ' '))"
|
|
68
|
+
fi
|
|
69
|
+
prompt=$(sed "s/{tag}/$1/g" "$PROMPT_FILE")
|
|
70
|
+
body=$(jq -n --arg model "$MODEL" --argjson max "$MAX_TOKENS" --arg text "$prompt"$'\n\n'"$input" \
|
|
71
|
+
'{model: $model, max_tokens: $max, messages: [{role: "user", content: $text}]}')
|
|
72
|
+
# The model thinks before it writes, so a small max_tokens runs out. Only a whole reply that
|
|
73
|
+
# names nothing forbidden is published (a release once went out cut mid-sentence). Anything
|
|
74
|
+
# else (max_tokens, a refusal, an error, a forbidden name) is asked again, at most 3 times.
|
|
75
|
+
text=
|
|
76
|
+
for attempt in 1 2 3; do
|
|
77
|
+
reply=$(curl -sS https://api.anthropic.com/v1/messages \
|
|
78
|
+
-H "x-api-key: $ANTHROPIC_API_KEY" -H 'anthropic-version: 2023-06-01' \
|
|
79
|
+
-H 'content-type: application/json' -d "$body")
|
|
80
|
+
text=$(jq -r 'if .stop_reason == "end_turn"
|
|
81
|
+
then [.content[]? | select(.type == "text") | .text] | join("") else "" end' <<< "$reply")
|
|
82
|
+
if [ -n "$text" ] && [ -n "$(named "$text")" ]; then
|
|
83
|
+
echo "attempt $attempt named a forbidden name; asking again" >&2
|
|
84
|
+
text=
|
|
85
|
+
elif [ -z "$text" ]; then
|
|
86
|
+
# show why, without the key
|
|
87
|
+
jq -c '{type, error, stop_reason, model, blocks: [.content[]?.type]}' <<< "$reply" >&2
|
|
88
|
+
fi
|
|
89
|
+
[ -n "$text" ] && break
|
|
90
|
+
sleep $((attempt * 5))
|
|
91
|
+
done
|
|
92
|
+
[ -n "$text" ] || return 1
|
|
93
|
+
printf '%s' "$text"
|
|
94
|
+
printf '\n\n## %s\n%s\n' "$count" "$list"
|
|
95
|
+
[ -z "${3:-}" ] || printf '\nFull changelog: https://github.com/%s/compare/%s...%s\n' "$REPO" "$3" "$1"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
write_notes() { # tag sha previous-tag [notes-file]: the notes for a release
|
|
99
|
+
if [ -n "${4:-}" ]; then
|
|
100
|
+
cat "$4"
|
|
101
|
+
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
|
|
102
|
+
notes "$1" "$2" "${3:-}" || { echo "Claude wrote no notes for $1" >&2; return 1; }
|
|
103
|
+
else
|
|
104
|
+
echo "ANTHROPIC_API_KEY is not set: the notes are GitHub's list of pull requests" >&2
|
|
105
|
+
gh api -X POST "repos/$REPO/releases/generate-notes" -f tag_name="$1" -f target_commitish="$2" \
|
|
106
|
+
${3:+-f previous_tag_name="$3"} --jq .body
|
|
107
|
+
fi
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Each Key takeaways bullet opens with its type tags, then app tags from changelog.apps:
|
|
111
|
+
# `- [Feature] [Fix] [Console] **Headline**: ...`. Type tags become the takeaway's chips, mapped
|
|
112
|
+
# app tags its `apps` (an app may show it only to people who can open one); unknown tags drop.
|
|
113
|
+
TAGS_JQ='map(capture("^(?<tags>(\\[[A-Za-z]+\\][ \t]*)*)(?<text>.*)$")
|
|
114
|
+
| [.tags | scan("\\[([A-Za-z]+)\\]")[0] | ascii_downcase] as $tags
|
|
115
|
+
| {text, types: [$tags[] | select(IN("feature", "improvement", "fix", "performance", "security"))] | unique,
|
|
116
|
+
apps: [$tags[] | $apps[.] // empty] | unique}
|
|
117
|
+
| if .apps == [] then del(.apps) else . end)'
|
|
118
|
+
changelog() { # tag published_at notes sha: commit the takeaways to main; prints the commit to tag
|
|
119
|
+
[ -n "$CHANGELOG" ] || { echo "$4"; return; }
|
|
120
|
+
bullets=$(printf '%s\n' "$3" | awk '
|
|
121
|
+
/^#{1,3}[ \t]/ { if (inside) exit; inside = tolower($0) ~ /key takeaways/; next }
|
|
122
|
+
inside && /^[ \t]*[-*][ \t]+/ { sub(/^[ \t]*[-*][ \t]+/, ""); print }' \
|
|
123
|
+
| jq -R . | jq -s --argjson apps "$APPS" "$TAGS_JQ")
|
|
124
|
+
commit=
|
|
125
|
+
for attempt in 1 2 3; do
|
|
126
|
+
file=$(gh api "repos/$REPO/contents/$CHANGELOG?ref=main") || break
|
|
127
|
+
next=$(jq -r .content <<< "$file" | base64 -d | jq --arg tag "$1" --arg at "$2" --argjson t "$bullets" \
|
|
128
|
+
'[{tag: $tag, published_at: $at, takeaways: $t}] + map(select(.tag != $tag))
|
|
129
|
+
| sort_by(.published_at) | reverse')
|
|
130
|
+
# A GITHUB_TOKEN commit starts no workflow, and the patch count skips `changelog:` commits.
|
|
131
|
+
commit=$(gh api -X PUT "repos/$REPO/contents/$CHANGELOG" -f message="changelog: $1" -f branch=main \
|
|
132
|
+
-f sha="$(jq -r .sha <<< "$file")" -f content="$(printf '%s\n' "$next" | base64 -w0)" \
|
|
133
|
+
--jq '[.commit.sha, .commit.parents[0].sha] | join(" ")') && break
|
|
134
|
+
commit=
|
|
135
|
+
sleep $((attempt * 2))
|
|
136
|
+
done
|
|
137
|
+
[ -n "$commit" ] || echo "could not write $1 to $CHANGELOG" >&2
|
|
138
|
+
read -r new parent <<< "${commit:-}"
|
|
139
|
+
# Tag the changelog commit when it sits right on the release; else the release's own commit
|
|
140
|
+
# (main moved, or the write failed: the release stands and the next one carries its notes).
|
|
141
|
+
if [ "${parent:-}" = "$4" ]; then echo "$new"; else echo "$4"; fi
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
promote() { # sha: fast-forward the promote branch to it, creating it the first time
|
|
145
|
+
[ -n "$PROMOTE" ] || return 0
|
|
146
|
+
if gh api "repos/$REPO/git/ref/heads/$PROMOTE" > /dev/null 2>&1; then
|
|
147
|
+
gh api -X PATCH "repos/$REPO/git/refs/heads/$PROMOTE" -f sha="$1" -F force=false > /dev/null
|
|
148
|
+
else
|
|
149
|
+
gh api -X POST "repos/$REPO/git/refs" -f ref="refs/heads/$PROMOTE" -f sha="$1" > /dev/null
|
|
150
|
+
fi
|
|
151
|
+
echo "$PROMOTE -> $(git log -1 --format='%h' "$1")"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
release() { # tag sha previous-tag [notes-file]: a new tag and its release
|
|
155
|
+
if [ "$DRY_RUN" = 1 ]; then
|
|
156
|
+
echo "would cut $1 (since ${3:-the start}) at $(git log -1 --format='%h %s' "$2")"
|
|
157
|
+
[ -z "$PROMOTE" ] || echo "and move $PROMOTE to it"
|
|
158
|
+
return
|
|
159
|
+
fi
|
|
160
|
+
body=$(write_notes "$@") || exit 1
|
|
161
|
+
bad=$(named "$body")
|
|
162
|
+
[ -z "$bad" ] || { echo "$1 notes name $bad: not published" >&2; exit 1; }
|
|
163
|
+
sha=$(changelog "$1" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$body" "$2")
|
|
164
|
+
at=$(gh api -X POST "repos/$REPO/releases" \
|
|
165
|
+
-f tag_name="$1" -f target_commitish="$sha" -f name="$1" -f body="$body" --jq .published_at)
|
|
166
|
+
echo "$1 $(git log -1 --format='%h %s' "$2" 2> /dev/null || echo "$sha") $at"
|
|
167
|
+
promote "$sha"
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
LATEST=$(git tag --list "$TAGS" --sort=-v:refname | head -1)
|
|
171
|
+
case "$MODE" in
|
|
172
|
+
tag)
|
|
173
|
+
TAG=${2:?usage: lumen-ship release tag v<version>}
|
|
174
|
+
git rev-parse -q --verify "refs/tags/$TAG" > /dev/null || { echo "no tag $TAG" >&2; exit 1; }
|
|
175
|
+
PREV=$(git tag --list "$TAGS" --sort=-v:refname | awk -v t="$TAG" 'found { print; exit } $0 == t { found = 1 }')
|
|
176
|
+
SHA=$(git rev-list -n1 "$TAG")
|
|
177
|
+
# A tag with no release answers 404, whose error body gh still prints: keep only an id.
|
|
178
|
+
ID=$(gh api "repos/$REPO/releases/tags/$TAG" --jq .id 2> /dev/null || true)
|
|
179
|
+
[[ "$ID" =~ ^[0-9]+$ ]] || ID=
|
|
180
|
+
if [ -n "$ID" ] && [ "$REDO" != 1 ]; then echo "$TAG already has a release"; exit; fi
|
|
181
|
+
body=$(write_notes "$TAG" "$SHA" "$PREV") || exit 1
|
|
182
|
+
bad=$(named "$body")
|
|
183
|
+
[ -z "$bad" ] || { echo "$TAG notes name $bad: not published" >&2; exit 1; }
|
|
184
|
+
if [ "$DRY_RUN" = 1 ]; then printf 'would release %s (since %s):\n\n%s\n' "$TAG" "${PREV:-the start}" "$body"; exit; fi
|
|
185
|
+
if [ -n "$ID" ]; then
|
|
186
|
+
gh api -X PATCH "repos/$REPO/releases/$ID" -f body="$body" --silent
|
|
187
|
+
echo "$TAG notes written again"
|
|
188
|
+
else
|
|
189
|
+
gh api -X POST "repos/$REPO/releases" -f tag_name="$TAG" -f name="$TAG" -f body="$body" --jq .html_url
|
|
190
|
+
fi
|
|
191
|
+
;;
|
|
192
|
+
minor | notes)
|
|
193
|
+
[ -n "$LATEST" ] || { echo "no v* tag yet: tag the first release by hand" >&2; exit 1; }
|
|
194
|
+
IFS=. read -r MAJOR MINOR _ <<< "${LATEST#v}"
|
|
195
|
+
if [ "$MODE" = minor ]; then
|
|
196
|
+
TAG="v$MAJOR.$((MINOR + 1)).0"; PREV="v$MAJOR.$MINOR.0"
|
|
197
|
+
else
|
|
198
|
+
TAG="v$MAJOR.$MINOR.0"
|
|
199
|
+
fi
|
|
200
|
+
FILE="docs/releases/$TAG.md"
|
|
201
|
+
git show "origin/main:$FILE" > "$TMP/notes.md" 2> /dev/null \
|
|
202
|
+
|| { echo "$FILE is not on main: write the key takeaways first" >&2; exit 1; }
|
|
203
|
+
if [ "$MODE" = minor ]; then release "$TAG" "$(git rev-parse origin/main)" "$PREV" "$TMP/notes.md"; exit; fi
|
|
204
|
+
if [ "$DRY_RUN" = 1 ]; then echo "would set $TAG notes from $FILE"; exit; fi
|
|
205
|
+
id=$(gh api "repos/$REPO/releases/tags/$TAG" --jq .id)
|
|
206
|
+
at=$(gh api -X PATCH "repos/$REPO/releases/$id" -F body=@"$TMP/notes.md" --jq .published_at)
|
|
207
|
+
echo "$TAG notes set from $FILE ($at)"
|
|
208
|
+
changelog "$TAG" "$at" "$(cat "$TMP/notes.md")" "" > /dev/null
|
|
209
|
+
;;
|
|
210
|
+
patch)
|
|
211
|
+
[ -n "$LATEST" ] || { echo "no v* tag yet: tag the first release by hand" >&2; exit 1; }
|
|
212
|
+
IFS=. read -r MAJOR MINOR PATCH <<< "${LATEST#v}"
|
|
213
|
+
# The changelog commits this script writes are not merges.
|
|
214
|
+
COUNT=$(git rev-list --first-parent --count --invert-grep --grep='^changelog: ' "$LATEST..origin/main")
|
|
215
|
+
if [ "$COUNT" = 0 ]; then echo "nothing merged since $LATEST"; exit; fi
|
|
216
|
+
AGE_DAYS=$(( ($(date +%s) - $(git log -1 --format=%ct "$LATEST")) / 86400 ))
|
|
217
|
+
if [ "$FORCE" != 1 ] && [ "$COUNT" -lt "$EVERY" ] && [ "$AGE_DAYS" -lt "$DAYS" ]; then
|
|
218
|
+
echo "$COUNT of $EVERY merges, $AGE_DAYS of $DAYS days since $LATEST: no release yet"
|
|
219
|
+
exit
|
|
220
|
+
fi
|
|
221
|
+
release "v$MAJOR.$MINOR.$((PATCH + 1))" "$(git rev-parse origin/main)" "$LATEST"
|
|
222
|
+
;;
|
|
223
|
+
*)
|
|
224
|
+
echo "usage: lumen-ship release [patch | minor | notes | tag v<version>]" >&2
|
|
225
|
+
exit 1
|
|
226
|
+
;;
|
|
227
|
+
esac
|