Skip to content

Commit 9fa0568

Browse files
tech-sumitAI Assistantclaude
authored
ci(extension): release workflow — build zip + signed CRX on ext-v* tag
* ci(extension): release workflow — build + publish zip on ext-v* tag Pushing a tag like `ext-v1.0.1` builds the MV3 package and publishes it as a GitHub Release with promptvault-extension.zip attached, ready to upload to the Chrome Web Store. Guards that the tag version matches both manifest.json and package.json before building, runs the extension tests, and verifies manifest.json sits at the zip root. Idempotent: updates the release if the tag was already released. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * ci(extension): also build a signed CRX as a release asset Adds a "Pack signed CRX" step to the release workflow and apps/extension/ scripts/pack-crx.mjs. When the CRX_PRIVATE_KEY secret is set, CI packs a CRX3 signed with that key and attaches it to the release alongside the zip; the step is skipped (zip only) when the secret is absent. The packer strips the store `key` from the CRX manifest so the CRX ID derives from the signing key (the store ID can't be reproduced without Google's key) and prints the resulting ID. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: AI Assistant <ai@example.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 5843df5 commit 9fa0568

2 files changed

Lines changed: 158 additions & 0 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
name: Release extension
2+
3+
# Build the Chrome Web Store package and publish it as a GitHub Release.
4+
# Trigger by pushing a tag like `ext-v1.0.1`. The tag's version must match
5+
# apps/extension/manifest.json, or the run fails before building.
6+
on:
7+
push:
8+
tags: ['ext-v*']
9+
workflow_dispatch:
10+
inputs:
11+
tag:
12+
description: 'Tag to release (must match manifest version, e.g. ext-v1.0.1)'
13+
required: true
14+
15+
permissions:
16+
contents: write
17+
18+
concurrency:
19+
group: release-extension-${{ github.event.inputs.tag || github.ref_name }}
20+
cancel-in-progress: false
21+
22+
jobs:
23+
release:
24+
runs-on: ubuntu-latest
25+
steps:
26+
- uses: actions/checkout@v4
27+
28+
- uses: actions/setup-node@v4
29+
with:
30+
node-version: 22
31+
cache: npm
32+
33+
- name: Install dependencies
34+
run: npm ci
35+
36+
- name: Resolve tag and verify version
37+
id: v
38+
run: |
39+
TAG="${{ github.event.inputs.tag || github.ref_name }}"
40+
case "$TAG" in
41+
ext-v*) ;;
42+
*) echo "::error::Tag '$TAG' must start with 'ext-v'"; exit 1 ;;
43+
esac
44+
VERSION="${TAG#ext-v}"
45+
MANIFEST_VERSION="$(node -p "require('./apps/extension/manifest.json').version")"
46+
PKG_VERSION="$(node -p "require('./apps/extension/package.json').version")"
47+
if [ "$VERSION" != "$MANIFEST_VERSION" ]; then
48+
echo "::error::Tag version '$VERSION' != manifest.json version '$MANIFEST_VERSION'"; exit 1
49+
fi
50+
if [ "$VERSION" != "$PKG_VERSION" ]; then
51+
echo "::error::Tag version '$VERSION' != package.json version '$PKG_VERSION'"; exit 1
52+
fi
53+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
54+
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
55+
56+
- name: Test extension
57+
run: npm -w apps/extension run test
58+
59+
- name: Build and package
60+
run: npm -w apps/extension run package
61+
62+
- name: Verify package contents
63+
run: |
64+
ZIP=apps/extension/promptvault-extension.zip
65+
test -f "$ZIP" || { echo "::error::package zip not produced"; exit 1; }
66+
# manifest.json must sit at the zip root (Chrome Web Store requirement)
67+
unzip -l "$ZIP" | grep -qE ' manifest\.json$' || {
68+
echo "::error::manifest.json missing at zip root"; exit 1; }
69+
echo "Package OK:"; unzip -l "$ZIP"
70+
71+
- name: Pack signed CRX (optional)
72+
id: crx
73+
env:
74+
CRX_KEY: ${{ secrets.CRX_PRIVATE_KEY }}
75+
run: |
76+
if [ -z "$CRX_KEY" ]; then
77+
echo "No CRX_PRIVATE_KEY secret set — skipping CRX (the zip is still published)."
78+
exit 0
79+
fi
80+
printf '%s\n' "$CRX_KEY" > /tmp/crx.pem
81+
npm install --no-save crx3 >/dev/null 2>&1
82+
CRX="apps/extension/promptvault-${{ steps.v.outputs.version }}.crx"
83+
node apps/extension/scripts/pack-crx.mjs /tmp/crx.pem "$CRX" apps/extension/dist
84+
rm -f /tmp/crx.pem
85+
echo "crx=$CRX" >> "$GITHUB_OUTPUT"
86+
87+
- name: Create or update release
88+
env:
89+
GH_TOKEN: ${{ github.token }}
90+
run: |
91+
TAG="${{ steps.v.outputs.tag }}"
92+
VERSION="${{ steps.v.outputs.version }}"
93+
ASSETS="apps/extension/promptvault-extension.zip"
94+
[ -n "${{ steps.crx.outputs.crx }}" ] && ASSETS="$ASSETS ${{ steps.crx.outputs.crx }}"
95+
NOTES="$(cat <<EOF
96+
Chrome Web Store package for PromptVault (Manifest V3).
97+
98+
**Install / submit:** download \`promptvault-extension.zip\` and upload it in the [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole). Listing field values are in \`apps/extension/store-assets/DASHBOARD-FIELDS.md\`.
99+
100+
- manifest version: \`$VERSION\`
101+
- built from \`apps/extension/src\` (esbuild, target chrome120) by CI from commit \`${{ github.sha }}\`
102+
- extension tests passing
103+
- \`.zip\` → Chrome Web Store upload; \`.crx\` (if present) → signed for sideloading, ID derived from your CRX key (not the Web Store ID)
104+
EOF
105+
)"
106+
if gh release view "$TAG" >/dev/null 2>&1; then
107+
gh release upload "$TAG" $ASSETS --clobber
108+
gh release edit "$TAG" --title "PromptVault Extension v$VERSION" --notes "$NOTES"
109+
else
110+
gh release create "$TAG" $ASSETS \
111+
--title "PromptVault Extension v$VERSION" --notes "$NOTES"
112+
fi
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Pack the built extension into a signed CRX3.
2+
//
3+
// Usage: node scripts/pack-crx.mjs <key.pem> <out.crx> [distDir=dist]
4+
//
5+
// The CRX is signed with YOUR private key, and the extension ID is derived from
6+
// that key — it will NOT match the Chrome Web Store ID (Google holds the store
7+
// key). So we strip the store `key` from the manifest in the CRX copy; otherwise
8+
// Chrome refuses to load it on a key mismatch. The resulting CRX ID is printed.
9+
import crx3 from 'crx3';
10+
import { createHash, createPublicKey, createPrivateKey } from 'node:crypto';
11+
import { readFileSync, writeFileSync, mkdtempSync, cpSync, existsSync } from 'node:fs';
12+
import { tmpdir } from 'node:os';
13+
import { join } from 'node:path';
14+
15+
const keyPath = process.argv[2];
16+
const outCrx = process.argv[3] || 'promptvault.crx';
17+
const distDir = process.argv[4] || join(new URL('.', import.meta.url).pathname, '..', 'dist');
18+
19+
if (!keyPath || !existsSync(keyPath)) {
20+
console.error('Private key not found. Usage: node scripts/pack-crx.mjs <key.pem> <out.crx> [distDir]');
21+
process.exit(1);
22+
}
23+
if (!existsSync(join(distDir, 'manifest.json'))) {
24+
console.error(`No manifest.json in ${distDir} — run the build first.`);
25+
process.exit(1);
26+
}
27+
28+
// Copy dist and drop the store `key` so the CRX ID comes from the signing key.
29+
const tmp = mkdtempSync(join(tmpdir(), 'pv-crx-'));
30+
cpSync(distDir, tmp, { recursive: true });
31+
const mfPath = join(tmp, 'manifest.json');
32+
const mf = JSON.parse(readFileSync(mfPath, 'utf8'));
33+
delete mf.key;
34+
writeFileSync(mfPath, JSON.stringify(mf, null, 2));
35+
36+
await crx3([mfPath], { keyPath, crxPath: outCrx });
37+
38+
// Derive and report the CRX extension ID from the signing key's public part.
39+
const der = createPublicKey(createPrivateKey(readFileSync(keyPath))).export({ type: 'spki', format: 'der' });
40+
const digest = createHash('sha256').update(der).digest();
41+
let id = '';
42+
for (let i = 0; i < 16; i++) id += String.fromCharCode(97 + (digest[i] >> 4)) + String.fromCharCode(97 + (digest[i] & 0xf));
43+
44+
console.log(`CRX written: ${outCrx}`);
45+
console.log(`CRX extension ID: ${id}`);
46+
console.log(`CRX redirect URI: https://${id}.chromiumapp.org/`);

0 commit comments

Comments
 (0)