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
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,66 @@ jobs:
- name: Push webproxy image
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: docker push boltcard/webproxy:latest

release:
runs-on: ubuntu-latest
needs: [docker]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Publish GitHub Release on version bump
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
V=$(grep -oP 'Version string = "\K[0-9]+\.[0-9]+\.[0-9]+' docker/card/build/build.go)
echo "Version in build.go: $V"

if git rev-parse -q --verify "refs/tags/v$V" >/dev/null; then
echo "Tag v$V already exists — nothing to release."
exit 0
fi

PREV=$(git tag -l 'v*' --sort=-v:refname | head -n1)
echo "Previous release tag: ${PREV:-(none)}"
if [ -n "$PREV" ]; then
RANGE="$PREV..HEAD"
else
RANGE="HEAD"
fi

# Tidied notes: non-merge commit subjects, with version-bump,
# docs:, and chore: lines dropped (incl. conventional-commit scopes).
mapfile -t SUBJECTS < <(git log --no-merges --pretty=format:'%s' $RANGE \
| grep -viE '^(bump|chore: ?bump).*version|^version bump|^bump to v?[0-9]|^docs(\(|:)|^chore(\(|:)' || true)

TOTAL=${#SUBJECTS[@]}
LIMIT=30
BODY=""
COUNT=0
for s in "${SUBJECTS[@]}"; do
[ "$COUNT" -ge "$LIMIT" ] && break
BODY+="- $s"$'\n'
COUNT=$((COUNT + 1))
done
if [ "$TOTAL" -gt "$LIMIT" ]; then
REMAIN=$((TOTAL - LIMIT))
BODY+=$'\n'"…and $REMAIN earlier commits."$'\n'
if [ -n "$PREV" ]; then
BODY+=$'\n'"Full changelog: https://github.com/boltcard/hub/compare/$PREV...v$V"$'\n'
fi
fi
if [ -z "$BODY" ]; then
BODY="Release v$V"
fi

echo "----- release notes -----"
printf '%s\n' "$BODY"
echo "-------------------------"

gh release create "v$V" --target "$GITHUB_SHA" --title "v$V" --notes "$BODY"
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
docker/card/card
notes
.github/prompts/*
# local planning docs
# local planning docs (brainstorm specs + implementation plans — kept local, not committed)
docs/plans/
docs/superpowers/
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Entry point: `main.go` → opens SQLite DB → runs CLI or starts HTTP server on
- `phoenix/` — HTTP client for Phoenix Server API (invoices, payments, balance, channels). Uses basic auth from phoenix config (password cached at startup with `sync.Once`)
- `crypto/` — AES-CMAC authentication and AES decryption for Bolt Card NFC protocol
- `util/` — Error handling helpers (`CheckAndLog`), random hex generation, QR code encoding
- `build/` — Version string (currently "0.21.0"), date/time injected at build
- `build/` — Version string (currently "0.22.0"), date/time injected at build
- `web-content/` — Static assets under `public/`, SPA build output under `admin/spa/`

### Route Groups (`web/app.go`)
Expand Down
172 changes: 118 additions & 54 deletions docker/card/admin-ui/src/pages/about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { ArrowUpCircle, Loader2 } from "lucide-react";
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { toast } from "sonner";

interface AboutData {
Expand All @@ -34,15 +34,81 @@ interface LogsData {
logs: string[];
}

interface Commit {
sha: string;
message: string;
date: string;
interface Release {
version: string;
name: string;
body: string;
date: string;
url: string;
isCurrent: boolean;
}

interface ReleasesData {
releases: Release[];
}

// linkify turns bare http(s) URLs in a line into anchor elements; other text is
// returned verbatim (React escapes it).
function linkify(text: string): ReactNode {
const parts = text.split(/(https?:\/\/[^\s]+)/g);
return parts.map((part, i) =>
/^https?:\/\//.test(part) ? (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
{part}
</a>
) : (
<span key={i}>{part}</span>
),
);
}

interface CommitsData {
commits: Commit[];
// ReleaseNotes renders a release body: "- " lines become a bullet list, blank
// lines break groups, everything else is a paragraph. No markdown dependency.
function ReleaseNotes({ body }: { body: string }) {
const elements: ReactNode[] = [];
let bullets: string[] = [];

const flush = () => {
if (bullets.length > 0) {
const items = bullets;
elements.push(
<ul
key={elements.length}
className="list-disc space-y-0.5 pl-5 text-sm"
>
{items.map((b, i) => (
<li key={i}>{linkify(b)}</li>
))}
</ul>,
);
bullets = [];
}
};

for (const raw of body.split("\n")) {
const line = raw.trimEnd();
if (line.startsWith("- ")) {
bullets.push(line.slice(2));
} else if (line.trim() === "") {
flush();
} else {
flush();
elements.push(
<p key={elements.length} className="text-sm text-muted-foreground">
{linkify(line)}
</p>,
);
}
}
flush();

return <div className="space-y-2">{elements}</div>;
}

export function AboutPage() {
Expand All @@ -56,9 +122,13 @@ export function AboutPage() {
queryFn: () => apiFetch<LogsData>("/about/logs"),
});

const { data: commitsData } = useQuery({
queryKey: ["about-commits"],
queryFn: () => apiFetch<CommitsData>("/about/commits"),
const { data: releasesData } = useQuery({
queryKey: ["about-releases", data?.latestVersion],
queryFn: () =>
apiFetch<ReleasesData>(
`/about/releases?latest=${encodeURIComponent(data?.latestVersion ?? "")}`,
),
enabled: !!data,
});

const [dialogOpen, setDialogOpen] = useState(false);
Expand Down Expand Up @@ -200,56 +270,50 @@ export function AboutPage() {
</Card>
)}

{commitsData && commitsData.commits.length > 0 && (
{releasesData && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Commits</CardTitle>
<CardTitle className="text-lg">Recent Releases</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{Object.entries(
commitsData.commits.reduce<Record<string, Commit[]>>(
(groups, c) => {
const day = new Date(c.date).toLocaleDateString(undefined, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
});
(groups[day] ??= []).push(c);
return groups;
},
{},
),
).map(([date, commits]) => (
<div key={date}>
<h3 className="mb-1 text-xs font-medium text-muted-foreground">
{date}
</h3>
<ul className="space-y-1">
{commits.map((c) => (
<li key={c.sha} className="flex items-baseline gap-2">
<a
href={`https://github.com/boltcard/hub/commit/${c.sha}`}
target="_blank"
rel="noopener noreferrer"
className="text-sm hover:underline"
>
{c.message}
</a>
{c.version && (
<span className="shrink-0 font-mono text-xs text-muted-foreground">
v{c.version}
</span>
)}
</li>
))}
</ul>
</div>
))}
<CardContent className="space-y-6">
{releasesData.releases.length === 0 ? (
<p className="text-sm text-muted-foreground">
No release notes available.
</p>
) : (
releasesData.releases.map((rel) => (
<div key={rel.version} className="space-y-2">
<div className="flex items-baseline gap-2">
<a
href={rel.url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-sm font-medium hover:underline"
>
v{rel.version}
</a>
{rel.isCurrent && (
<Badge variant="secondary" className="text-xs">
Current
</Badge>
)}
{rel.date && (
<span className="text-xs text-muted-foreground">
{new Date(rel.date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</span>
)}
</div>
{rel.body.trim() && <ReleaseNotes body={rel.body} />}
</div>
))
)}
</CardContent>
</Card>
)}

</div>
);
}
2 changes: 1 addition & 1 deletion docker/card/build/build.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
package build

var Version string = "0.21.0"
var Version string = "0.22.0"
var Date string
var Time string
4 changes: 2 additions & 2 deletions docker/card/web/admin_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ func (app *App) CreateHandler_AdminApi() http.HandlerFunc {
case path == "/admin/api/about/logs" && r.Method == "GET":
app.adminApiAuth(app.adminApiLogs)(w, r)

case path == "/admin/api/about/commits" && r.Method == "GET":
app.adminApiAuth(app.adminApiCommits)(w, r)
case path == "/admin/api/about/releases" && r.Method == "GET":
app.adminApiAuth(app.adminApiReleases)(w, r)

case path == "/admin/api/database/stats" && r.Method == "GET":
app.adminApiAuth(app.adminApiDatabaseStats)(w, r)
Expand Down
Loading
Loading