diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..ab8028a --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release unsigned Firefox extension + +on: + release: + types: + - published + +permissions: + code: read + releases: write + +jobs: + package-and-attach: + name: Package and attach unsigned XPI + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Check out the released tag + uses: https://gitea.com/actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + ref: ${{ gitea.event.release.tag_name }} + fetch-depth: 1 + + - name: Set up Node.js + uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 + with: + node-version: 22 + + - name: Run automated checks + run: npm test + + - name: Build unsigned release archive + env: + GITEA_RELEASE_TAG: ${{ gitea.event.release.tag_name }} + run: npm run package -- --version "${GITEA_RELEASE_TAG}" --output-dir dist + + - name: Verify release checksum + run: sha256sum --check dist/*.xpi.sha256 + + - name: Attach release assets + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_RELEASE_ID: ${{ gitea.event.release.id }} + run: | + set -eu + for asset in dist/*.xpi dist/*.xpi.sha256; do + name="$(basename "${asset}")" + curl --fail-with-body --silent --show-error \ + --request POST \ + --header "Authorization: token ${GITEA_TOKEN}" \ + --form "attachment=@${asset}" \ + "${GITEA_SERVER_URL%/}/api/v1/repos/${GITEA_REPOSITORY}/releases/${GITEA_RELEASE_ID}/assets?name=${name}" + done diff --git a/README.md b/README.md index 4b07ce2..b85d749 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,16 @@ Shafarik's license is `fonts/Shafarik-OFL.txt`; Noto Sans Glagolitic's license i The selected font is inserted before each affected element's existing computed font family, so non-Glagolitic characters continue to use the page's own typography. If the page genuinely changes an affected element's inline `font-family` while Glagolify is enabled, the extension retains that latest page-owned declaration, reapplies the selected Glagolitic fallback, and restores the latest page value when switched off. +## Release pipeline + +Publishing a Gitea release runs `.gitea/workflows/release.yml`. The workflow checks out the release tag, runs the tests, builds an unsigned XPI, verifies its SHA-256 checksum, and attaches both files to that release using Gitea's built-in job token. + +Use a Mozilla-compatible numeric tag such as `1.2.3` or `v1.2.3`. A leading `v` is removed. The resulting release assets are `glagolify-1.2.3.xpi` and `glagolify-1.2.3.xpi.sha256`. The release tag overrides `package.json` without modifying the tagged source; the packaged `manifest.json` receives the normalized release version. Draft releases do not run the pipeline until they are published. + +The repository or owner Actions settings must allow the job token to write releases. No personal access token or signing credential is required. The XPI is unsigned and therefore intended for Mozilla submission, temporary installation, or Firefox configurations that explicitly permit unsigned extensions. + +To reproduce the archive locally with the version from `package.json`, run `npm run package`. Override it with `npm run package -- --version v1.2.3`. Packaging includes only extension runtime files and writes the archive and checksum under `dist/`. + ## Automated checks No install or build step is required. With Node.js available, run: diff --git a/package.json b/package.json index c75bf6b..d14ac95 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "description": "A dependency-free Firefox extension for toggling modern Russian text to Glagolitic.", "scripts": { + "package": "node scripts/package-extension.js", "test": "node --test" } } diff --git a/scripts/package-extension.js b/scripts/package-extension.js new file mode 100644 index 0000000..ade3463 --- /dev/null +++ b/scripts/package-extension.js @@ -0,0 +1,206 @@ +"use strict"; + +const { createHash } = require("node:crypto"); +const { mkdir, readFile, writeFile } = require("node:fs/promises"); +const path = require("node:path"); +const { deflateRawSync } = require("node:zlib"); + +const ARCHIVE_FILES = Object.freeze([ + "background.js", + "content.css", + "content.js", + "fonts/NotoSansGlagolitic-Regular.ttf", + "fonts/OFL.txt", + "fonts/Shafarik-OFL.txt", + "fonts/Shafarik-Regular.ttf", + "icons/icon-16.svg", + "icons/icon-32.svg", + "icons/icon-48.svg", + "icons/icon-96.svg", + "manifest.json", + "popup.css", + "popup.html", + "popup.js", + "transliterate.js" +]); + +const VERSION_PATTERN = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/; +const UTF8_FLAG = 0x0800; +const DEFLATE_METHOD = 8; +const DOS_TIME = 0; +const DOS_DATE = (1 << 5) | 1; + +function normalizeVersion(value) { + if (typeof value !== "string" || value.length === 0) { + throw new Error("A package or release version is required."); + } + + const version = /^[vV]/.test(value) ? value.slice(1) : value; + if (!VERSION_PATTERN.test(version)) { + throw new Error( + `Invalid Mozilla extension version ${JSON.stringify(value)}. ` + + "Use one to four dot-separated integers without leading zeroes; a leading v is allowed." + ); + } + + return version; +} + +function crc32(buffer) { + let crc = 0xffffffff; + + for (const byte of buffer) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); + } + } + + return (crc ^ 0xffffffff) >>> 0; +} + +function createZip(entries) { + const localRecords = []; + const centralRecords = []; + let offset = 0; + + for (const entry of entries) { + const name = Buffer.from(entry.name, "utf8"); + const compressed = deflateRawSync(entry.contents, { level: 9 }); + const checksum = crc32(entry.contents); + + const localHeader = Buffer.alloc(30); + localHeader.writeUInt32LE(0x04034b50, 0); + localHeader.writeUInt16LE(20, 4); + localHeader.writeUInt16LE(UTF8_FLAG, 6); + localHeader.writeUInt16LE(DEFLATE_METHOD, 8); + localHeader.writeUInt16LE(DOS_TIME, 10); + localHeader.writeUInt16LE(DOS_DATE, 12); + localHeader.writeUInt32LE(checksum, 14); + localHeader.writeUInt32LE(compressed.length, 18); + localHeader.writeUInt32LE(entry.contents.length, 22); + localHeader.writeUInt16LE(name.length, 26); + localHeader.writeUInt16LE(0, 28); + + const localRecord = Buffer.concat([localHeader, name, compressed]); + localRecords.push(localRecord); + + const centralHeader = Buffer.alloc(46); + centralHeader.writeUInt32LE(0x02014b50, 0); + centralHeader.writeUInt16LE(0x0314, 4); + centralHeader.writeUInt16LE(20, 6); + centralHeader.writeUInt16LE(UTF8_FLAG, 8); + centralHeader.writeUInt16LE(DEFLATE_METHOD, 10); + centralHeader.writeUInt16LE(DOS_TIME, 12); + centralHeader.writeUInt16LE(DOS_DATE, 14); + centralHeader.writeUInt32LE(checksum, 16); + centralHeader.writeUInt32LE(compressed.length, 20); + centralHeader.writeUInt32LE(entry.contents.length, 24); + centralHeader.writeUInt16LE(name.length, 28); + centralHeader.writeUInt16LE(0, 30); + centralHeader.writeUInt16LE(0, 32); + centralHeader.writeUInt16LE(0, 34); + centralHeader.writeUInt16LE(0, 36); + centralHeader.writeUInt32LE((0o100644 << 16) >>> 0, 38); + centralHeader.writeUInt32LE(offset, 42); + centralRecords.push(Buffer.concat([centralHeader, name])); + + offset += localRecord.length; + } + + const centralDirectory = Buffer.concat(centralRecords); + const endRecord = Buffer.alloc(22); + endRecord.writeUInt32LE(0x06054b50, 0); + endRecord.writeUInt16LE(0, 4); + endRecord.writeUInt16LE(0, 6); + endRecord.writeUInt16LE(entries.length, 8); + endRecord.writeUInt16LE(entries.length, 10); + endRecord.writeUInt32LE(centralDirectory.length, 12); + endRecord.writeUInt32LE(offset, 16); + endRecord.writeUInt16LE(0, 20); + + return Buffer.concat([...localRecords, centralDirectory, endRecord]); +} + +async function packageExtension({ rootDirectory, outputDirectory, version: requestedVersion }) { + const packageMetadata = JSON.parse( + await readFile(path.join(rootDirectory, "package.json"), "utf8") + ); + const version = normalizeVersion(requestedVersion ?? packageMetadata.version); + const entries = []; + + for (const relativePath of ARCHIVE_FILES) { + let contents = await readFile(path.join(rootDirectory, relativePath)); + + if (relativePath === "manifest.json") { + const manifest = JSON.parse(contents.toString("utf8")); + manifest.version = version; + contents = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + } + + entries.push({ name: relativePath, contents }); + } + + const archive = createZip(entries); + const archiveName = `glagolify-${version}.xpi`; + const archivePath = path.join(outputDirectory, archiveName); + const checksumPath = `${archivePath}.sha256`; + const digest = createHash("sha256").update(archive).digest("hex"); + + await mkdir(outputDirectory, { recursive: true }); + await writeFile(archivePath, archive); + await writeFile(checksumPath, `${digest} ${archiveName}\n`); + + return { archiveName, archivePath, checksumPath, digest, version }; +} + +function parseArguments(arguments_) { + let outputDirectory = "dist"; + let version; + + for (let index = 0; index < arguments_.length; index += 1) { + const argument = arguments_[index]; + + if (argument === "--version" || argument === "--output-dir") { + const value = arguments_[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${argument} requires a value.`); + } + + if (argument === "--version") { + version = value; + } else { + outputDirectory = value; + } + index += 1; + } else { + throw new Error(`Unknown argument: ${argument}`); + } + } + + return { outputDirectory, version }; +} + +async function main() { + const rootDirectory = path.resolve(__dirname, ".."); + const options = parseArguments(process.argv.slice(2)); + const result = await packageExtension({ + rootDirectory, + outputDirectory: path.resolve(rootDirectory, options.outputDirectory), + version: options.version + }); + + process.stdout.write( + `Created ${path.relative(rootDirectory, result.archivePath)} for version ${result.version}\n` + + `SHA-256 ${result.digest}\n` + ); +} + +if (require.main === module) { + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); +} + +module.exports = { ARCHIVE_FILES, normalizeVersion, packageExtension }; diff --git a/tests/package-extension.test.js b/tests/package-extension.test.js new file mode 100644 index 0000000..535e860 --- /dev/null +++ b/tests/package-extension.test.js @@ -0,0 +1,86 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createHash } = require("node:crypto"); +const { mkdtemp, readFile, rm } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { inflateRawSync } = require("node:zlib"); +const { + ARCHIVE_FILES, + normalizeVersion, + packageExtension +} = require("../scripts/package-extension.js"); + +function readZipEntries(archive) { + const entries = new Map(); + let offset = 0; + + while (archive.readUInt32LE(offset) === 0x04034b50) { + const method = archive.readUInt16LE(offset + 8); + const compressedSize = archive.readUInt32LE(offset + 18); + const nameLength = archive.readUInt16LE(offset + 26); + const extraLength = archive.readUInt16LE(offset + 28); + const nameStart = offset + 30; + const dataStart = nameStart + nameLength + extraLength; + const name = archive.subarray(nameStart, nameStart + nameLength).toString("utf8"); + const compressed = archive.subarray(dataStart, dataStart + compressedSize); + + assert.equal(method, 8, `${name} should use DEFLATE compression`); + entries.set(name, inflateRawSync(compressed)); + offset = dataStart + compressedSize; + } + + assert.equal(archive.readUInt32LE(offset), 0x02014b50, "central directory is present"); + return entries; +} + +test("normalizes release tags and rejects versions Firefox cannot publish", () => { + assert.equal(normalizeVersion("1.2.3"), "1.2.3"); + assert.equal(normalizeVersion("v2.0"), "2.0"); + assert.equal(normalizeVersion("V3"), "3"); + + for (const version of ["", "v", "1.2.3.4.5", "1.02", "1.0-beta", "1000000000"]) { + assert.throws(() => normalizeVersion(version), /Invalid Mozilla extension version|required/); + } +}); + +test("builds a deterministic unsigned XPI with the release version and runtime files only", async (context) => { + const rootDirectory = path.resolve(__dirname, ".."); + const firstOutput = await mkdtemp(path.join(os.tmpdir(), "glagolify-package-")); + const secondOutput = await mkdtemp(path.join(os.tmpdir(), "glagolify-package-")); + context.after(async () => { + await Promise.all([ + rm(firstOutput, { recursive: true, force: true }), + rm(secondOutput, { recursive: true, force: true }) + ]); + }); + + const first = await packageExtension({ + rootDirectory, + outputDirectory: firstOutput, + version: "v9.8.7" + }); + const second = await packageExtension({ + rootDirectory, + outputDirectory: secondOutput, + version: "9.8.7" + }); + const firstArchive = await readFile(first.archivePath); + const secondArchive = await readFile(second.archivePath); + const entries = readZipEntries(firstArchive); + + assert.equal(first.archiveName, "glagolify-9.8.7.xpi"); + assert.deepEqual([...entries.keys()], [...ARCHIVE_FILES]); + assert.equal(JSON.parse(entries.get("manifest.json")).version, "9.8.7"); + assert.equal(entries.has("package.json"), false); + assert.equal(entries.has("tests/package-extension.test.js"), false); + assert.deepEqual(firstArchive, secondArchive); + + const digest = createHash("sha256").update(firstArchive).digest("hex"); + assert.equal( + await readFile(first.checksumPath, "utf8"), + `${digest} glagolify-9.8.7.xpi\n` + ); +});