-
Notifications
You must be signed in to change notification settings - Fork 0
337 lines (327 loc) · 15.2 KB
/
Copy pathci.yml
File metadata and controls
337 lines (327 loc) · 15.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
name: ci
# Label each run so builds are identifiable at a glance (ported from the old Python pipeline):
# • workflow_dispatch → "CI — New release (<version|bump>)"
# • tag push (v*) → "CI — New release (<tag>)"
# • pull request → "CI - PR <title>"
# • push → "CI — <commit message>"
run-name: >-
${{ github.event_name == 'workflow_dispatch'
&& format('CI — New release ({0})', inputs.version != '' && inputs.version || inputs.bump)
|| (startsWith(github.ref, 'refs/tags/v')
&& format('CI — New release ({0})', github.ref_name)
|| (github.event_name == 'pull_request'
&& format('CI - PR {0}', github.event.pull_request.title)
|| format('CI — {0}', github.event.head_commit.message))) }}
# Three scenarios (mirrors the old pipeline):
# 1. Push to main / pull request → lint + typecheck + test + build + sonar
# 2. Tag push (v*) → … + publish (npm) + GitHub Release (.mcpb) + Homebrew bump
# 3. workflow_dispatch (Release) → bump version → tag → publish + Release + Homebrew
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
inputs:
bump:
description: "Semver bump"
type: choice
options: [patch, minor, major]
default: patch
version:
description: "Exact version (overrides bump), e.g. 0.4.0"
type: string
default: ""
permissions:
contents: read
jobs:
# ── Lint + typecheck + test + build (Node LTS floor + latest) ──────────────
check:
runs-on: ubuntu-latest
strategy:
matrix:
node: ["24", "26"] # 24 = current LTS floor (node:sqlite); 26 = latest stable
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm run test:cov
- run: npm run build
# Hand the lcov report to the sonar job so it doesn't re-install + re-test.
- uses: actions/upload-artifact@v7
if: matrix.node == '24'
with:
name: coverage-lcov
path: coverage/lcov.info
retention-days: 1
# ── SonarQube Cloud: code quality + coverage (skips when SONAR_TOKEN unset) ──
sonar:
needs: check
runs-on: ubuntu-latest
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # full history for accurate blame / new-code detection
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
- run: npm ci # node_modules → type-aware analysis (resolves dependency types)
# Reuse the check job's coverage instead of re-running the whole test suite.
- uses: actions/download-artifact@v8
with:
name: coverage-lcov
path: coverage
# Cache the scanner CLI + analyzer/JRE so it isn't re-downloaded each run.
- name: Cache SonarQube scanner
uses: actions/cache@v6
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
# Tag the analysis with the package version (single source of truth);
# not hardcoded in sonar-project.properties so it can't drift.
- name: Read version for Sonar
run: echo "PKG_VERSION=$(node -p "require('./package.json').version")" >> "$GITHUB_ENV"
- name: SonarQube Cloud scan
if: ${{ env.SONAR_TOKEN != '' }}
uses: SonarSource/sonarqube-scan-action@v8
with:
args: -Dsonar.projectVersion=${{ env.PKG_VERSION }}
# ── Version bump → commit → tag (workflow_dispatch only) ───────────────────
bump:
if: github.event_name == 'workflow_dispatch'
needs: check
runs-on: ubuntu-latest
# Serialize releases: a second dispatch queues behind the first instead of racing it
# (rapid re-triggers were stacking version bumps).
concurrency:
group: release
cancel-in-progress: false
permissions:
contents: write
outputs:
tag: ${{ steps.bump.outputs.tag }}
version: ${{ steps.bump.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
# Full history + tags so the preflight guards (last tag, "nothing to release") work.
fetch-depth: 0
# Admin PAT (fine-grained, dev-coach only, Contents: RW) so the push to the
# protected main branch bypasses branch protection (enforce_admins is off).
# The default GITHUB_TOKEN runs as github-actions[bot], which is not an admin
# and would be rejected by the required CLA check.
token: ${{ secrets.RELEASE_TOKEN }}
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
- name: Preflight — refuse an empty release
run: |
set -euo pipefail
git fetch --tags --force --quiet origin
LAST="$(git describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)"
if [ -n "$LAST" ]; then
N="$(git log "${LAST}..HEAD" --invert-grep --grep='^chore: release' --oneline | wc -l | tr -d ' ')"
[ "$N" != "0" ] || { echo "::error::Nothing to release since ${LAST} (only release commits) — aborting empty bump."; exit 1; }
fi
- name: Bump, commit, tag, push
id: bump
run: |
set -euo pipefail
TARGET="${{ inputs.version != '' && inputs.version || inputs.bump }}"
NEW="$(npm version "$TARGET" --no-git-tag-version)"
NEW="${NEW#v}"
# Fail fast if the tag already exists — prevents the stray-tag cascade / version skew.
if git rev-parse -q --verify "refs/tags/v${NEW}" >/dev/null \
|| git ls-remote --exit-code --tags origin "v${NEW}" >/dev/null 2>&1; then
echo "::error::Tag v${NEW} already exists — delete it first: git push origin :refs/tags/v${NEW}"
exit 1
fi
# keep the .mcpb manifest version in sync (the build also syncs it; commit it tidy)
NEW="$NEW" node -e 'const fs=require("fs"),f="mcpb/manifest.json";fs.writeFileSync(f,fs.readFileSync(f,"utf8").replace(/"version": "[^"]+"/,`"version": "${process.env.NEW}"`))'
# keep the Claude Code plugin manifest + bundled SKILL.md in sync with package.json
node scripts/sync-plugin.mjs
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json mcpb/manifest.json \
plugin/.claude-plugin/plugin.json plugin/skills/devcoach/SKILL.md plugin/package.json
# [skip ci] so neither the main push nor the tag push (both made with a real
# PAT identity, which DOES re-trigger workflows) starts a second run that would
# double-publish. Publish/release still run in THIS workflow_dispatch run via needs.bump.
git commit -m "chore: release v${NEW} [skip ci]"
git tag "v${NEW}"
# --atomic: main commit and tag land together or not at all — no half-state where
# main got the bump but the tag push failed (which silently skips a version).
git push --atomic origin HEAD:main "v${NEW}"
echo "version=${NEW}" >> "$GITHUB_OUTPUT"
echo "tag=v${NEW}" >> "$GITHUB_OUTPUT"
# ── Publish to npm (OIDC provenance) ───────────────────────────────────────
publish:
needs: [check, bump]
if: >-
${{ always() && needs.check.result == 'success'
&& (startsWith(github.ref, 'refs/tags/v') || needs.bump.result == 'success') }}
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance / OIDC trusted publishing (tokenless)
deployments: write # record an "npm" deployment for the Deployments dashboard
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.bump.outputs.tag || github.ref }}
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
registry-url: https://registry.npmjs.org
cache: npm
- run: npm ci
- run: npm run build
# Tokenless: trusted publishing exchanges the OIDC token for publish
# rights, and provenance is generated automatically (no --provenance,
# no NODE_AUTH_TOKEN). Requires the GitHub trusted publisher registered
# on npmjs.com for this repo + workflow file (environment left blank).
- run: npm publish --access public
# Surface npm in the repo's Deployments dashboard. Done via the API rather than a job-level
# `environment:` key on purpose: an environment would change the OIDC subject claim and break
# trusted publishing unless mirrored in npmjs.com's trusted-publisher settings.
- name: Record npm deployment
uses: actions/github-script@v9
with:
script: |
const { version } = require(`${process.env.GITHUB_WORKSPACE}/package.json`);
const common = { owner: context.repo.owner, repo: context.repo.repo };
const dep = await github.rest.repos.createDeployment({
...common, ref: context.sha, environment: 'npm',
auto_merge: false, required_contexts: [],
production_environment: true, description: 'Published to npm',
});
await github.rest.repos.createDeploymentStatus({
...common, deployment_id: dep.data.id, state: 'success',
environment_url: `https://www.npmjs.com/package/devcoach/v/${version}`,
description: 'Published to npm',
});
# ── GitHub Release with the signed .mcpb attached ──────────────────────────
release:
needs: [check, bump]
if: >-
${{ always() && needs.check.result == 'success'
&& (startsWith(github.ref, 'refs/tags/v') || needs.bump.result == 'success') }}
runs-on: ubuntu-latest
permissions:
contents: write
env:
MCPB_CERT_PEM: ${{ secrets.MCPB_CERT }}
MCPB_KEY_PEM: ${{ secrets.MCPB_KEY }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.bump.outputs.tag || github.ref }}
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
cache: npm
- run: npm ci
- name: Configure signing certificate (real cert if secrets set, else self-signed)
run: |
if [ -n "$MCPB_CERT_PEM" ] && [ -n "$MCPB_KEY_PEM" ]; then
printf '%s' "$MCPB_CERT_PEM" > "$RUNNER_TEMP/mcpb-cert.pem"
printf '%s' "$MCPB_KEY_PEM" > "$RUNNER_TEMP/mcpb-key.pem"
echo "MCPB_CERT=$RUNNER_TEMP/mcpb-cert.pem" >> "$GITHUB_ENV"
echo "MCPB_KEY=$RUNNER_TEMP/mcpb-key.pem" >> "$GITHUB_ENV"
fi
- run: npm run mcpb:sign
# Claude Code plugin zip (offline-installable marketplace; vector C). build-mcpb already ran
# `npm run build`, and plugin:zip re-syncs the plugin before packing.
- run: npm run plugin:zip
- name: Generate SBOM + checksums for release assets
run: |
set -euo pipefail
# CycloneDX SBOM of production deps (npm sbom is built into npm >= 10.7 — no extra dep).
npm sbom --sbom-format cyclonedx --omit dev > dist-mcpb/devcoach.cdx.json
# Stage the plugin zip next to the .mcpb so one checksum file covers every release asset.
cp dist-plugin/devcoach-plugin-*.zip dist-mcpb/
# Checksums over every release asset (.mcpb + zip + SBOM); run inside the dir so paths are bare.
(cd dist-mcpb && shasum -a 256 *.mcpb *.zip *.cdx.json > SHASUMS256.txt)
- uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.bump.outputs.tag || github.ref_name }}
generate_release_notes: true
files: |
dist-mcpb/*.mcpb
dist-mcpb/*.zip
dist-mcpb/devcoach.cdx.json
dist-mcpb/SHASUMS256.txt
# ── Bump the Homebrew tap formula (skips when HOMEBREW_TAP_TOKEN unset) ─────
homebrew:
needs: [publish, bump]
if: ${{ always() && needs.publish.result == 'success' }}
runs-on: ubuntu-latest
environment:
name: homebrew-tap
url: https://github.com/UltimaPhoenix/homebrew-tap/blob/main/Formula/devcoach.rb
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.bump.outputs.tag || github.ref }}
- name: Update devcoach.rb in UltimaPhoenix/homebrew-tap
if: ${{ env.TAP_TOKEN != '' }}
run: |
set -euo pipefail
VERSION="${{ needs.bump.outputs.version || github.ref_name }}"
VERSION="${VERSION#v}"
URL="https://registry.npmjs.org/devcoach/-/devcoach-${VERSION}.tgz"
# npm may take a moment to serve a freshly-published version
for _ in $(seq 1 12); do curl -fsSL "$URL" -o pkg.tgz && break || sleep 15; done
SHA="$(shasum -a 256 pkg.tgz | awk '{print $1}')"
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/UltimaPhoenix/homebrew-tap.git" tap
mkdir -p tap/Formula
node scripts/homebrew-formula.mjs "$VERSION" "$SHA" > tap/Formula/devcoach.rb
cd tap
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/devcoach.rb
git diff --staged --quiet || git commit -m "devcoach ${VERSION}"
git push
# ── Sync the devcoach entry in the plugin marketplace tap (skips when token unset) ──
marketplace:
needs: [publish, bump]
if: ${{ always() && needs.publish.result == 'success' }}
runs-on: ubuntu-latest
environment:
name: claude-marketplace
url: https://github.com/UltimaPhoenix/claude-plugins-marketplace
env:
MARKETPLACE_TOKEN: ${{ secrets.CLAUDE_PLUGIN_MARKETPLACE }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.bump.outputs.tag || github.ref }}
- uses: actions/setup-node@v6
with:
node-version-file: .node-version
- name: Pin devcoach in UltimaPhoenix/claude-plugins-marketplace
if: ${{ env.MARKETPLACE_TOKEN != '' }}
run: |
set -euo pipefail
VERSION="${{ needs.bump.outputs.version || github.ref_name }}"
VERSION="${VERSION#v}"
git clone --depth 1 "https://x-access-token:${MARKETPLACE_TOKEN}@github.com/UltimaPhoenix/claude-plugins-marketplace.git" mkt
# Surgical merge: updates only the devcoach entry, leaving other plugins untouched.
node scripts/update-marketplace.mjs "$VERSION" "v${VERSION}" mkt/.claude-plugin/marketplace.json
cd mkt
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .claude-plugin/marketplace.json
git diff --staged --quiet || git commit -m "devcoach ${VERSION}"
git push