release pipeline

This commit is contained in:
2026-08-08 19:39:47 +02:00
parent e0ed0d5467
commit 17fef0f2cf
5 changed files with 359 additions and 0 deletions
+56
View File
@@ -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
+10
View File
@@ -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. 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 ## Automated checks
No install or build step is required. With Node.js available, run: No install or build step is required. With Node.js available, run:
+1
View File
@@ -4,6 +4,7 @@
"private": true, "private": true,
"description": "A dependency-free Firefox extension for toggling modern Russian text to Glagolitic.", "description": "A dependency-free Firefox extension for toggling modern Russian text to Glagolitic.",
"scripts": { "scripts": {
"package": "node scripts/package-extension.js",
"test": "node --test" "test": "node --test"
} }
} }
+206
View File
@@ -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 };
+86
View File
@@ -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`
);
});