Skip to content

Commit cedd882

Browse files
Disane87claude
andcommitted
ci: add semantic-release pipeline with GHCR release deploy
Releases are now driven by Conventional Commits via semantic-release: version bump, GitHub release, changelog, and a semver-tagged Docker image pushed to GHCR. docker-publish.yml is reduced to PR validation builds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8fd45c7 commit cedd882

11 files changed

Lines changed: 9999 additions & 3676 deletions

File tree

.github/workflows/docker-publish.yml

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
name: Build & publish Docker image
1+
name: Validate Docker build
2+
3+
# Release images are published by release.yml (semantic-release driven).
4+
# This workflow only validates that the image still builds on pull requests,
5+
# and lets you build/push an ad-hoc image manually when needed.
26

37
on:
4-
push:
5-
branches: [main, master]
6-
tags: ['v*.*.*']
78
pull_request:
89
branches: [main, master]
910
workflow_dispatch:
@@ -21,16 +22,16 @@ jobs:
2122
- name: Checkout
2223
uses: actions/checkout@v4
2324

24-
# GHCR image names must be lowercase derive from owner/repo.
25+
# GHCR image names must be lowercase; derive from owner/repo.
2526
- name: Compute lowercase image name
2627
run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
2728

2829
- name: Set up Docker Buildx
2930
uses: docker/setup-buildx-action@v3
3031

31-
# Only log in / push for real events, never for fork pull requests.
32+
# Only log in for manual runs (which push); pull requests build only.
3233
- name: Log in to GHCR
33-
if: github.event_name != 'pull_request'
34+
if: github.event_name == 'workflow_dispatch'
3435
uses: docker/login-action@v3
3536
with:
3637
registry: ${{ env.REGISTRY }}
@@ -43,18 +44,15 @@ jobs:
4344
with:
4445
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
4546
tags: |
46-
type=ref,event=branch
4747
type=ref,event=pr
48-
type=semver,pattern={{version}}
49-
type=semver,pattern={{major}}.{{minor}}
5048
type=sha,format=short
51-
type=raw,value=latest,enable={{is_default_branch}}
49+
type=raw,value=edge,enable=${{ github.event_name == 'workflow_dispatch' }}
5250
53-
- name: Build and push
51+
- name: Build (and push on manual run)
5452
uses: docker/build-push-action@v6
5553
with:
5654
context: .
57-
push: ${{ github.event_name != 'pull_request' }}
55+
push: ${{ github.event_name == 'workflow_dispatch' }}
5856
tags: ${{ steps.meta.outputs.tags }}
5957
labels: ${{ steps.meta.outputs.labels }}
6058
cache-from: type=gha

.github/workflows/release.yml

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
# Prevent overlapping releases from racing each other.
9+
concurrency:
10+
group: release
11+
cancel-in-progress: false
12+
13+
env:
14+
REGISTRY: ghcr.io
15+
16+
jobs:
17+
release:
18+
runs-on: ubuntu-latest
19+
permissions:
20+
contents: write # create tags, releases, push changelog commit
21+
issues: write # comment on released issues
22+
pull-requests: write # comment on released PRs
23+
packages: write # push the container image to GHCR
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
with:
28+
fetch-depth: 0
29+
persist-credentials: true
30+
31+
- name: Setup Node
32+
uses: actions/setup-node@v4
33+
with:
34+
node-version: 22
35+
cache: npm
36+
37+
- name: Install dependencies
38+
run: npm ci
39+
40+
# Determines the next version from conventional commits, updates the
41+
# changelog + package.json, creates the git tag and GitHub release, and
42+
# exports the version via @semantic-release/exec -> $GITHUB_OUTPUT.
43+
- name: Semantic release
44+
id: semrel
45+
run: npx semantic-release
46+
env:
47+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48+
49+
# Everything below only runs when a new version was actually published.
50+
- name: Compute image name & tags
51+
if: steps.semrel.outputs.version != ''
52+
run: |
53+
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
54+
VER='${{ steps.semrel.outputs.version }}'
55+
{
56+
echo "TAG_VERSION=$VER"
57+
echo "TAG_MAJOR_MINOR=${VER%.*}"
58+
} >> "$GITHUB_ENV"
59+
60+
- name: Set up Docker Buildx
61+
if: steps.semrel.outputs.version != ''
62+
uses: docker/setup-buildx-action@v3
63+
64+
- name: Log in to GHCR
65+
if: steps.semrel.outputs.version != ''
66+
uses: docker/login-action@v3
67+
with:
68+
registry: ${{ env.REGISTRY }}
69+
username: ${{ github.actor }}
70+
password: ${{ secrets.GITHUB_TOKEN }}
71+
72+
- name: Build & push release image
73+
if: steps.semrel.outputs.version != ''
74+
uses: docker/build-push-action@v6
75+
with:
76+
context: .
77+
push: true
78+
tags: |
79+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.TAG_VERSION }}
80+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.TAG_MAJOR_MINOR }}
81+
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
82+
labels: |
83+
org.opencontainers.image.version=${{ env.TAG_VERSION }}
84+
org.opencontainers.image.revision=${{ github.sha }}
85+
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
86+
cache-from: type=gha
87+
cache-to: type=gha,mode=max

.releaserc.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"branches": ["main"],
3+
"plugins": [
4+
"@semantic-release/commit-analyzer",
5+
"@semantic-release/release-notes-generator",
6+
[
7+
"@semantic-release/changelog",
8+
{
9+
"changelogTitle": "# Changelog\n\nAll notable changes to hon.ey are documented here. This file is generated by semantic-release."
10+
}
11+
],
12+
[
13+
"@semantic-release/npm",
14+
{
15+
"npmPublish": false
16+
}
17+
],
18+
[
19+
"@semantic-release/exec",
20+
{
21+
"verifyReleaseCmd": "echo \"version=${nextRelease.version}\" >> $GITHUB_OUTPUT"
22+
}
23+
],
24+
"@semantic-release/github",
25+
[
26+
"@semantic-release/git",
27+
{
28+
"assets": ["package.json", "package-lock.json", "CHANGELOG.md"],
29+
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
30+
}
31+
]
32+
]
33+
}

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
![Vue](https://img.shields.io/badge/Vue-3-42b883?logo=vuedotjs&logoColor=white)
55
![GitHub license](https://img.shields.io/github/license/Disane87/honey)
66
![GitHub issues](https://img.shields.io/github/issues/Disane87/honey?color=red)
7+
[![semantic-release: conventionalcommits](https://img.shields.io/badge/semantic--release-conventionalcommits-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release)
78

89

910
# 🍯 hon.ey URL Honeypot
@@ -172,6 +173,26 @@ the `custom` trap renders operator-authored HTML verbatim. Don't use any of this
172173
impersonate third parties, because that crosses into phishing and is illegal in most places. 🙏
173174

174175

176+
# 🚀 Releases
177+
178+
Versioning is fully automated with [semantic-release](https://semantic-release.gitbook.io/). Every
179+
push to `main` is analyzed, and your [Conventional Commits](https://www.conventionalcommits.org/)
180+
decide what happens next:
181+
182+
- 🐛 `fix:` → patch release (1.0.**1**)
183+
-`feat:` → minor release (1.**1**.0)
184+
- 💥 `feat!:` or a `BREAKING CHANGE:` footer → major release (**2**.0.0)
185+
- 🧹 `chore:`, `docs:`, `refactor:`, `test:` and friends → no release
186+
187+
When a release is cut, the pipeline automatically:
188+
189+
1. 🏷️ Tags the commit and creates a **GitHub Release** with generated notes
190+
2. 📝 Updates [`CHANGELOG.md`](CHANGELOG.md) and the version in `package.json`
191+
3. 🐳 Builds and pushes the Docker image to GHCR as `:x.y.z`, `:x.y`, and `:latest`
192+
193+
So just write good commit messages and let the robots do the boring part! 🤖✨
194+
195+
175196
# 📄 License
176197

177198
[MIT](LICENSE). Do good things with it.

app/assets/css/main.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,14 @@ tr.clickable:hover td { background: var(--surface-2); }
237237
.kv dd { margin: 0; font-family: var(--mono); font-size: 12.5px; word-break: break-word; color: var(--ink); }
238238
.headers-box { background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 14px 16px; font-family: var(--mono); font-size: 12px; max-height: 320px; overflow: auto; white-space: pre-wrap; word-break: break-all; color: var(--ink-soft); }
239239

240+
/* ---------- geo / flag + map ---------- */
241+
.geo-cell { display: inline-flex; align-items: center; gap: 7px; }
242+
.flag { font-size: 16px; border-radius: 999px; flex-shrink: 0; }
243+
.hit-map { position: relative; border: 1px solid var(--border); border-radius: var(--r-sm); overflow: hidden; margin: 8px 4px 16px; }
244+
.hit-map iframe { width: 100%; height: 200px; border: 0; display: block; }
245+
.hit-map-pill { position: absolute; left: 12px; bottom: 12px; display: inline-flex; align-items: center; gap: 8px; background: rgba(46, 36, 21, .88); color: #fff; padding: 7px 13px; border-radius: 999px; font-size: 12.5px; font-weight: 600; -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); }
246+
.hit-map-pill .flag { font-size: 16px; }
247+
240248
/* ---------- cloned OG preview card ---------- */
241249
.og-card { display: flex; flex-direction: column; max-width: 540px; border: 1px solid var(--border); border-radius: var(--r); overflow: hidden; background: var(--surface-2); box-shadow: var(--shadow-sm); }
242250
.og-img { width: 100%; max-height: 260px; object-fit: cover; display: block; background: var(--bg-tint); }

app/composables/useFormat.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,44 @@ export function useFormat() {
2222
return [hit.geo.city, hit.geo.country].filter(Boolean).join(', ') || '—'
2323
}
2424

25+
/**
26+
* Iconify name for a hit's country flag from the locally-bundled
27+
* `circle-flags` collection. Falls back to a neutral globe when the
28+
* geo lookup yielded no country code.
29+
*/
30+
function flagIcon(hit: Hit): string {
31+
const cc = hit.geo?.countryCode?.toLowerCase()
32+
return cc ? `circle-flags:${cc}` : 'lucide:globe'
33+
}
34+
35+
function hasCoords(hit: Hit): boolean {
36+
return typeof hit.geo?.lat === 'number' && typeof hit.geo?.lon === 'number'
37+
}
38+
39+
/**
40+
* OpenStreetMap embed URL framing a hit's coordinates, with a marker.
41+
* Returns '' when the hit has no usable coordinates.
42+
*/
43+
function mapUrl(hit: Hit): string {
44+
if (!hasCoords(hit)) return ''
45+
const lat = hit.geo!.lat as number
46+
const lon = hit.geo!.lon as number
47+
const dx = 3.2
48+
const dy = 1.7
49+
const bbox = [lon - dx, lat - dy, lon + dx, lat + dy]
50+
.map((n) => n.toFixed(4))
51+
.join('%2C')
52+
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat.toFixed(4)}%2C${lon.toFixed(4)}`
53+
}
54+
2555
function uaLabel(hit: Hit): string {
2656
const u = hit.ua
2757
const browser = [u.browser, u.browserVersion?.split('.')[0]].filter(Boolean).join(' ')
2858
const os = [u.os, u.osVersion].filter(Boolean).join(' ')
2959
return [browser, os].filter(Boolean).join(' · ') || (hit.userAgent ? 'Unknown client' : 'No UA')
3060
}
3161

32-
return { relTime, absTime, geoLabel, uaLabel }
62+
return { relTime, absTime, geoLabel, uaLabel, flagIcon, hasCoords, mapUrl }
3363
}
3464

3565
export function trapUrls(slug: string, type: string, origin: string) {

app/pages/feed.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Hit, Trap } from '~~/server/utils/types'
33
44
const { data: hits, refresh } = await useFetch<Hit[]>('/api/hits')
55
const { data: traps } = await useFetch<Trap[]>('/api/traps')
6-
const { relTime, absTime, geoLabel, uaLabel } = useFormat()
6+
const { relTime, absTime, geoLabel, uaLabel, flagIcon } = useFormat()
77
88
const trapName = (id: string) => traps.value?.find(t => t.id === id)?.name || 'unknown trap'
99
@@ -40,7 +40,7 @@ onBeforeUnmount(() => clearInterval(poll))
4040
<td :title="absTime(h.ts)" data-allow-mismatch>{{ relTime(h.ts) }}</td>
4141
<td><span style="font-weight:600">{{ trapName(h.trapId) }}</span></td>
4242
<td class="code">{{ h.ip }}</td>
43-
<td>{{ geoLabel(h) }}</td>
43+
<td><span class="geo-cell"><Icon :name="flagIcon(h)" class="flag" /> {{ geoLabel(h) }}</span></td>
4444
<td>{{ uaLabel(h) }}</td>
4545
<td>
4646
<span class="badge" :class="h.isBot ? 'bot' : 'human'">

app/pages/traps/[id].vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { trapUrls, usePublicOrigin } from '~/composables/useFormat'
55
const route = useRoute()
66
const id = route.params.id as string
77
const { data, refresh, pending } = await useFetch<{ trap: Trap; hits: Hit[] }>(`/api/traps/${id}`)
8-
const { relTime, absTime, geoLabel, uaLabel } = useFormat()
8+
const { relTime, absTime, geoLabel, uaLabel, flagIcon, hasCoords, mapUrl } = useFormat()
99
1010
const origin = usePublicOrigin()
1111
const expanded = ref<string | null>(null)
@@ -174,7 +174,7 @@ onBeforeUnmount(() => clearInterval(poll))
174174
<tr class="clickable" @click="toggle(h.id)">
175175
<td :title="absTime(h.ts)" data-allow-mismatch>{{ relTime(h.ts) }}</td>
176176
<td class="code">{{ h.ip }}</td>
177-
<td>{{ geoLabel(h) }}</td>
177+
<td><span class="geo-cell"><Icon :name="flagIcon(h)" class="flag" /> {{ geoLabel(h) }}</span></td>
178178
<td>{{ uaLabel(h) }}</td>
179179
<td>
180180
<span class="badge" :class="h.isBot ? 'bot' : 'human'">
@@ -184,6 +184,13 @@ onBeforeUnmount(() => clearInterval(poll))
184184
</tr>
185185
<tr v-if="expanded === h.id">
186186
<td colspan="5" style="background: var(--surface-2);">
187+
<div v-if="hasCoords(h)" class="hit-map">
188+
<iframe :src="mapUrl(h)" title="Hit location" loading="lazy"></iframe>
189+
<div class="hit-map-pill">
190+
<Icon :name="flagIcon(h)" class="flag" />
191+
{{ geoLabel(h) }}<template v-if="h.geo?.isp"> · {{ h.geo.isp }}</template>
192+
</div>
193+
</div>
187194
<div class="row" style="gap: 32px; padding: 8px 4px 16px;">
188195
<dl class="kv" style="flex:1; min-width: 280px;">
189196
<dt>Timestamp</dt><dd>{{ absTime(h.ts) }}</dd>

nuxt.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ export default defineNuxtConfig({
88
modules: ['@nuxt/icon'],
99

1010
icon: {
11-
// Bundle the Lucide collection so icons resolve locally (no outbound CDN).
12-
serverBundle: { collections: ['lucide'] }
11+
// Bundle the Lucide + circle-flags collections so icons resolve locally (no outbound CDN).
12+
serverBundle: { collections: ['lucide', 'circle-flags'] }
1313
},
1414

1515
app: {

0 commit comments

Comments
 (0)