Skip to content

Commit e88dc96

Browse files
committed
feat(release): automate GitHub extension releases
Add Release Please versioning and publish verified Chromium and Firefox artifacts with checksums.
1 parent 26fad06 commit e88dc96

9 files changed

Lines changed: 275 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ jobs:
2929
- run: pnpm build
3030
- run: pnpm audit --prod
3131
- run: node scripts/package.mjs firefox
32+
- run: node scripts/package.mjs release
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Release Please
2+
3+
on:
4+
push:
5+
branches: [main]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: write
10+
issues: write
11+
pull-requests: write
12+
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.ref }}
15+
cancel-in-progress: true
16+
17+
jobs:
18+
release-please:
19+
runs-on: ubuntu-latest
20+
timeout-minutes: 10
21+
steps:
22+
- name: Check release token
23+
env:
24+
RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }}
25+
run: |
26+
if [ -z "$RELEASE_PLEASE_TOKEN" ]; then
27+
echo "RELEASE_PLEASE_TOKEN must be set so generated tags can trigger release.yml." >&2
28+
exit 1
29+
fi
30+
- name: Run release-please
31+
uses: googleapis/release-please-action@v4
32+
with:
33+
token: ${{ secrets.RELEASE_PLEASE_TOKEN }}
34+
config-file: release-please-config.json
35+
manifest-file: .release-please-manifest.json

.github/workflows/release.yml

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*.*.*"
7+
8+
concurrency:
9+
group: ${{ github.workflow }}-${{ github.ref }}
10+
cancel-in-progress: true
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
release:
17+
runs-on: ubuntu-latest
18+
timeout-minutes: 30
19+
permissions:
20+
contents: write
21+
env:
22+
VITE_CLERK_OAUTH_CLIENT_ID: ${{ vars.VITE_CLERK_OAUTH_CLIENT_ID }}
23+
VITE_CLERK_OAUTH_ISSUER: ${{ vars.VITE_CLERK_OAUTH_ISSUER }}
24+
VITE_WEB_APP_URL: ${{ vars.VITE_WEB_APP_URL }}
25+
steps:
26+
- name: Checkout release tag
27+
uses: actions/checkout@v4
28+
- name: Setup pnpm
29+
uses: pnpm/action-setup@v4
30+
- name: Setup Node.js
31+
uses: actions/setup-node@v4
32+
with:
33+
node-version: 22
34+
cache: pnpm
35+
- name: Validate release configuration
36+
id: version
37+
run: |
38+
for name in \
39+
VITE_CLERK_OAUTH_CLIENT_ID \
40+
VITE_CLERK_OAUTH_ISSUER \
41+
VITE_WEB_APP_URL
42+
do
43+
if [ -z "${!name}" ]; then
44+
echo "Required release variable or secret is missing: $name" >&2
45+
exit 1
46+
fi
47+
done
48+
49+
package_version="$(node -p "require('./package.json').version")"
50+
if [ "$GITHUB_REF_NAME" != "v${package_version}" ]; then
51+
echo "Tag $GITHUB_REF_NAME does not match package.json version ${package_version}." >&2
52+
exit 1
53+
fi
54+
echo "VERSION=${package_version}" >> "$GITHUB_ENV"
55+
echo "version=${package_version}" >> "$GITHUB_OUTPUT"
56+
- name: Install dependencies
57+
run: pnpm install --frozen-lockfile
58+
- name: Verify release source
59+
run: |
60+
pnpm lint
61+
pnpm test
62+
- name: Build release packages
63+
run: pnpm package:release
64+
- name: Download signed Firefox package
65+
run: |
66+
metadata="$(mktemp)"
67+
trap 'rm -f "$metadata"' EXIT
68+
curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \
69+
https://addons.mozilla.org/api/v5/addons/addon/memos-web-clipper/ \
70+
--output "$metadata"
71+
72+
amo_version="$(jq -r '.current_version.version // empty' "$metadata")"
73+
download_url="$(jq -r '.current_version.file.url // empty' "$metadata")"
74+
if [ "$amo_version" != "$VERSION" ] || [ -z "$download_url" ]; then
75+
echo "Firefox Add-ons publishes ${amo_version:-no version}; expected ${VERSION}. Publish the release candidate there before merging the Release Please PR." >&2
76+
exit 1
77+
fi
78+
79+
curl --fail --location --retry 3 --proto '=https' --proto-redir '=https' \
80+
"$download_url" \
81+
--output "artifacts/memos-web-clipper-firefox-v${VERSION}.xpi"
82+
- name: Verify release packages
83+
run: |
84+
verify_stage="$(mktemp -d)"
85+
trap 'rm -rf "$verify_stage"' EXIT
86+
mkdir "$verify_stage/unsigned-firefox" "$verify_stage/signed-firefox"
87+
unzip -p "artifacts/memos-web-clipper-chromium-v${VERSION}.zip" manifest.json > "$verify_stage/chromium.json"
88+
unzip -p "artifacts/memos-web-clipper-firefox-v${VERSION}.xpi" manifest.json > "$verify_stage/firefox.json"
89+
unzip -q "artifacts/memos-web-clipper-firefox-v${VERSION}.zip" -d "$verify_stage/unsigned-firefox"
90+
unzip -q "artifacts/memos-web-clipper-firefox-v${VERSION}.xpi" -d "$verify_stage/signed-firefox"
91+
rm -rf "$verify_stage/signed-firefox/META-INF"
92+
diff -qr "$verify_stage/unsigned-firefox" "$verify_stage/signed-firefox"
93+
94+
node --input-type=module - "$verify_stage" "$VERSION" <<'NODE'
95+
import { readFileSync } from "node:fs";
96+
import { join } from "node:path";
97+
98+
const [stage, version] = process.argv.slice(2);
99+
const chromium = JSON.parse(readFileSync(join(stage, "chromium.json"), "utf8"));
100+
const firefox = JSON.parse(readFileSync(join(stage, "firefox.json"), "utf8"));
101+
if (chromium.version !== version || !chromium.key) throw new Error("Chromium release manifest has the wrong version or identity key.");
102+
if (firefox.version !== version) throw new Error("Firefox release manifest has the wrong version.");
103+
if (firefox.browser_specific_settings?.gecko?.id !== "web-clipper@usememos.com") {
104+
throw new Error("Firefox release manifest has the wrong add-on ID.");
105+
}
106+
NODE
107+
- name: Generate checksums
108+
working-directory: artifacts
109+
run: |
110+
sha256sum \
111+
"memos-web-clipper-chromium-v${VERSION}.zip" \
112+
"memos-web-clipper-firefox-v${VERSION}.xpi" \
113+
> SHA256SUMS
114+
- name: Publish release assets
115+
uses: softprops/action-gh-release@v2
116+
with:
117+
tag_name: ${{ github.ref_name }}
118+
files: |
119+
artifacts/memos-web-clipper-chromium-v${{ steps.version.outputs.version }}.zip
120+
artifacts/memos-web-clipper-firefox-v${{ steps.version.outputs.version }}.xpi
121+
artifacts/SHA256SUMS

.release-please-manifest.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
".": "0.1.0"
3+
}

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ Save pages, selections, and images directly to your Memos instance. Available fo
77
- [Chrome Web Store](https://chromewebstore.google.com/detail/memos-web-clipper/nebaoebnljalfegiidibihhkebeiklbl)
88
- [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/memos-web-clipper/)
99

10+
### Manual installation
11+
12+
Versioned packages and checksums are available from [GitHub Releases](https://github.com/usememos/web-clipper/releases). Store installation is recommended for automatic updates.
13+
14+
For Chromium-based browsers:
15+
16+
1. Download and extract `memos-web-clipper-chromium-v<version>.zip`.
17+
2. Open the browser's extensions page, such as `chrome://extensions` or `edge://extensions`.
18+
3. Enable **Developer mode**, choose **Load unpacked**, and select the extracted directory.
19+
20+
For Firefox, download `memos-web-clipper-firefox-v<version>.xpi` and open it with Firefox. GitHub release XPIs are the same Mozilla-signed packages published on Firefox Add-ons.
21+
22+
To verify a download on Linux, run `sha256sum --ignore-missing -c SHA256SUMS` in the directory containing the downloaded package. Do not rely on manually installed packages receiving browser-store-managed updates; check GitHub Releases for new versions.
23+
1024
## Features
1125

1226
- Capture the page title, URL, and readable description.

docs/RELEASING.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Releasing
2+
3+
Release Please maintains the release pull request, `CHANGELOG.md`, `package.json` version, Git tag, and GitHub Release. A separate tag workflow verifies the source, builds the extension packages, mirrors the matching Mozilla-signed XPI, generates checksums, and attaches the user-facing assets.
4+
5+
## Repository configuration
6+
7+
Add a fine-grained personal access token as the `RELEASE_PLEASE_TOKEN` Actions secret. It needs read/write access to repository contents, issues, and pull requests. A separate token is required because tags created with the default `GITHUB_TOKEN` do not trigger the tag-based release workflow.
8+
9+
Add these public build values as Actions repository variables:
10+
11+
- `VITE_CLERK_OAUTH_CLIENT_ID`
12+
- `VITE_CLERK_OAUTH_ISSUER`
13+
- `VITE_WEB_APP_URL`
14+
15+
The release workflow rejects missing values and verifies that the tag exactly matches the version in `package.json`.
16+
17+
## Release process
18+
19+
1. Merge conventional commits into `main`. Release Please creates or updates a release pull request.
20+
2. Check out the release pull request branch, configure the production `VITE_*` values in `.env`, and run `pnpm package` from a clean worktree.
21+
3. Test the generated packages and submit the Firefox ZIP plus its matching source archive to Firefox Add-ons. Submit the Chromium store packages as appropriate.
22+
4. Wait until Firefox Add-ons publishes the exact version proposed by the release pull request.
23+
5. Merge the release pull request.
24+
6. Release Please creates the `v<version>` tag and GitHub Release. The tag workflow builds the same Firefox payload, downloads the signed XPI from Firefox Add-ons, compares every non-signature file, and publishes the release assets only when they match.
25+
26+
If Firefox Add-ons does not yet expose the expected version, the release workflow fails without publishing mismatched artifacts. Publish the store version and rerun the failed workflow.
27+
28+
The public release contains:
29+
30+
- `memos-web-clipper-chromium-v<version>.zip`, which retains the public manifest key needed for a stable OAuth extension ID.
31+
- `memos-web-clipper-firefox-v<version>.xpi`, mirrored from Firefox Add-ons after payload verification.
32+
- `SHA256SUMS` for both packages.
33+
34+
Store upload archives remain separate. `pnpm package` produces those archives, while `pnpm package:release` produces the Chromium sideload archive and an unsigned Firefox archive used only to verify the signed store package.
35+
36+
## Bootstrap
37+
38+
Version `0.1.0` was published before Release Please was introduced. The manifest records it as the current release, and `bootstrap-sha` points at the initial extension commit so later conventional commits are included in the first generated release pull request. Once the first Release Please release is complete, the bootstrap setting can be removed.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"dev": "vite",
1111
"build": "tsc --noEmit && vite build",
1212
"package": "pnpm build && node scripts/package.mjs all",
13+
"package:release": "pnpm build && node scripts/package.mjs release",
1314
"package:chrome": "pnpm build && node scripts/package.mjs chrome",
1415
"package:edge": "pnpm build && node scripts/package.mjs edge",
1516
"package:firefox": "pnpm build && node scripts/package.mjs firefox",

release-please-config.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3+
"include-v-in-tag": true,
4+
"include-component-in-tag": false,
5+
"bump-minor-pre-major": true,
6+
"bootstrap-sha": "d22945071e2c63a1b25b75278b46c503a01c608c",
7+
"packages": {
8+
".": {
9+
"release-type": "node",
10+
"package-name": "@usememos/web-clipper",
11+
"changelog-path": "CHANGELOG.md",
12+
"changelog-sections": [
13+
{ "type": "feat", "section": "Features" },
14+
{ "type": "fix", "section": "Bug Fixes" },
15+
{ "type": "perf", "section": "Performance Improvements" },
16+
{ "type": "deps", "section": "Dependencies" },
17+
{ "type": "revert", "section": "Reverts" }
18+
]
19+
}
20+
}
21+
}

scripts/package.mjs

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Builds store-specific upload archives from one fresh dist/ build.
1+
// Builds store-upload and manual-release archives from one fresh dist/ build.
22
//
33
// Chromium stores share the exact same package. Firefox needs an MV3 background
44
// script fallback, a stable Gecko ID, and data-collection declarations. AMO also
@@ -19,7 +19,7 @@ const FIREFOX_ADDON_ID = "web-clipper@usememos.com";
1919
// Firefox desktop gained built-in data consent in 140; Android gained it in 142.
2020
// `gecko.strict_min_version` covers both unless a separate Android manifest is used.
2121
const FIREFOX_MIN_VERSION = "142.0";
22-
const VALID_TARGETS = new Set(["all", "chrome", "edge", "firefox"]);
22+
const VALID_TARGETS = new Set(["all", "release", "chrome", "edge", "firefox"]);
2323

2424
const requestedTarget = process.argv[2] ?? "all";
2525
if (!VALID_TARGETS.has(requestedTarget)) {
@@ -60,7 +60,7 @@ if (baseManifest.version !== packageJson.version) {
6060
throw new Error(`Version mismatch: package.json is ${packageJson.version}, dist manifest is ${baseManifest.version}.`);
6161
}
6262

63-
if (requestedTarget === "all") rmSync(ARTIFACTS, { recursive: true, force: true });
63+
if (requestedTarget === "all" || requestedTarget === "release") rmSync(ARTIFACTS, { recursive: true, force: true });
6464
mkdirSync(ARTIFACTS, { recursive: true });
6565

6666
const zipDirectory = (sourceDir, outputPath) => {
@@ -100,13 +100,39 @@ const storeManifest = (target) => {
100100
return manifest;
101101
};
102102

103-
const createStoreStage = (target) => {
104-
const stage = mkdtempSync(join(tmpdir(), `memos-web-clipper-${target}-`));
103+
const manualChromiumManifest = () => {
104+
const manifest = structuredClone(baseManifest);
105+
// Manual Chromium installs need the public key so chrome.identity uses the
106+
// registered store extension ID regardless of the extracted directory.
107+
if (!manifest.key) throw new Error("The manual Chromium package requires a manifest key to preserve its OAuth identity.");
108+
delete manifest.update_url;
109+
return manifest;
110+
};
111+
112+
const createStage = (label, manifest) => {
113+
const stage = mkdtempSync(join(tmpdir(), `memos-web-clipper-${label}-`));
105114
cpSync(DIST, stage, { recursive: true });
106-
writeFileSync(join(stage, "manifest.json"), `${JSON.stringify(storeManifest(target), null, 2)}\n`);
115+
writeFileSync(join(stage, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
107116
return stage;
108117
};
109118

119+
const createStoreStage = (target) => {
120+
return createStage(target, storeManifest(target));
121+
};
122+
123+
const packageReleaseChromium = () => {
124+
const stage = createStage("chromium-release", manualChromiumManifest());
125+
const outputPath = join(ARTIFACTS, `memos-web-clipper-chromium-v${packageJson.version}.zip`);
126+
127+
try {
128+
zipDirectory(stage, outputPath);
129+
} finally {
130+
rmSync(stage, { recursive: true, force: true });
131+
}
132+
133+
return outputPath;
134+
};
135+
110136
const packageChromium = (targets) => {
111137
const stage = createStoreStage("chromium");
112138
const chromePath = join(ARTIFACTS, `memos-web-clipper-chrome-v${packageJson.version}.zip`);
@@ -170,7 +196,7 @@ const packageFirefoxSource = () => {
170196
return sourcePath;
171197
};
172198

173-
const packageFirefox = () => {
199+
const packageFirefox = ({ includeSource = isGitCheckout } = {}) => {
174200
const stage = createStoreStage("firefox");
175201
const firefoxPath = join(ARTIFACTS, `memos-web-clipper-firefox-v${packageJson.version}.zip`);
176202

@@ -185,10 +211,16 @@ const packageFirefox = () => {
185211
rmSync(stage, { recursive: true, force: true });
186212
}
187213

188-
return { firefoxPath, sourcePath: isGitCheckout ? packageFirefoxSource() : null };
214+
return { firefoxPath, sourcePath: includeSource && isGitCheckout ? packageFirefoxSource() : null };
189215
};
190216

191217
const created = [];
218+
if (requestedTarget === "release") {
219+
created.push(packageReleaseChromium());
220+
const { firefoxPath, sourcePath } = packageFirefox({ includeSource: false });
221+
created.push(firefoxPath);
222+
if (sourcePath) created.push(sourcePath);
223+
}
192224
if (requestedTarget === "all" || requestedTarget === "chrome" || requestedTarget === "edge") {
193225
created.push(...packageChromium(requestedTarget === "all" ? ["chrome", "edge"] : [requestedTarget]));
194226
}
@@ -198,6 +230,6 @@ if (requestedTarget === "all" || requestedTarget === "firefox") {
198230
if (sourcePath) created.push(sourcePath);
199231
}
200232

201-
console.log("\nCreated store artifacts:");
233+
console.log("\nCreated artifacts:");
202234
for (const file of created) console.log(`- artifacts/${basename(file)}`);
203235
console.log("\ndist/ remains the unpacked development build with its stable Chrome key.");

0 commit comments

Comments
 (0)