ci: add checks and release automation

This commit is contained in:
Peter Li 2026-07-16 17:13:25 +08:00
parent cbea428d5c
commit e0a82bd7b1
7 changed files with 448 additions and 0 deletions

16
.github/workflows/checks.yml vendored Normal file
View file

@ -0,0 +1,16 @@
name: Checks
on: [push, pull_request]
permissions:
contents: read
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run check
- run: git diff --exit-code -- theme.css

23
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,23 @@
name: Release Obsidian theme
on:
push:
tags: ["*"]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm run check
- run: node scripts/check-theme.mjs --release-tag "$GITHUB_REF_NAME"
- run: git diff --exit-code -- theme.css
- name: Create draft release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --generate-notes --draft manifest.json theme.css

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# Aera
A quiet interface for clear thinking.
Aera is an Obsidian theme for calm, readable Chinese-first notes with light,
dark, desktop, and mobile support.
![Aera screenshot](screenshot.png)
## Development
Run `npm install`, `npm run build`, and `npm run check`.
For live preview, set `AERA_TEST_VAULT`, then run `npm run link:vault` and
`npm run dev`. Changes to `manifest.json` require an Obsidian restart, while
compiled CSS changes automatically reload in the linked vault.
## Release assets
Every release contains `manifest.json` and `theme.css`.
## License
[MIT](LICENSE)

View file

@ -7,6 +7,7 @@
"dev": "sass --watch --no-source-map --style=expanded --no-error-css src/theme.scss:theme.css",
"build": "sass --no-source-map --style=expanded --no-error-css src/theme.scss theme.css",
"link:vault": "node scripts/link-vault.mjs",
"version:sync": "node scripts/version-bump.mjs",
"test": "node --test",
"check": "npm run build && node --test && node scripts/check-theme.mjs && node scripts/contrast.mjs"
},

184
scripts/version-bump.mjs Normal file
View file

@ -0,0 +1,184 @@
import { randomUUID } from "node:crypto";
import { realpathSync } from "node:fs";
import { open, readFile, rename, rm } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { validateManifest, validateVersions } from "./theme-policy.mjs";
const modulePath = fileURLToPath(import.meta.url);
function throwValidationErrors(errors, context = "") {
if (errors.length) {
const prefix = context ? `${context}: ` : "";
throw new Error(`${prefix}${errors.join("; ")}`);
}
}
export function bumpMetadata(manifest, versions, targetVersion) {
throwValidationErrors(validateManifest(manifest), "manifest.json");
throwValidationErrors(validateVersions(manifest, versions), "versions.json");
const targetErrors = validateManifest({ ...manifest, version: targetVersion });
if (targetErrors.includes("manifest version must use x.y.z")) {
throw new Error("target version must use x.y.z");
}
throwValidationErrors(targetErrors);
const nextManifest = { ...manifest, version: targetVersion };
const nextVersions = {
...versions,
[targetVersion]: manifest.minAppVersion,
};
throwValidationErrors([
...validateManifest(nextManifest),
...validateVersions(nextManifest, nextVersions),
]);
return { manifest: nextManifest, versions: nextVersions };
}
function jsonContents(value) {
return `${JSON.stringify(value, null, 2)}\n`;
}
async function readJson(path) {
let contents;
try {
contents = await readFile(path, "utf8");
} catch (error) {
throw new Error(`could not read ${path}: ${error.message}`);
}
try {
return JSON.parse(contents);
} catch (error) {
throw new Error(`could not parse ${path}: ${error.message}`);
}
}
async function stageFile(path, contents, id) {
const temporaryPath = join(dirname(path), `.${basename(path)}.aera-${id}.tmp`);
let temporaryFile;
let ownsTemporaryPath = false;
try {
temporaryFile = await open(temporaryPath, "wx");
ownsTemporaryPath = true;
await temporaryFile.writeFile(contents);
await temporaryFile.close();
temporaryFile = undefined;
return {
path: temporaryPath,
release() {
ownsTemporaryPath = false;
},
async cleanup() {
await temporaryFile?.close().catch(() => {});
if (ownsTemporaryPath) {
await rm(temporaryPath, { force: true });
ownsTemporaryPath = false;
}
},
};
} catch (error) {
await temporaryFile?.close().catch(() => {});
if (ownsTemporaryPath) await rm(temporaryPath, { force: true }).catch(() => {});
throw error;
}
}
async function replaceTogether(replacements) {
const transactionId = randomUUID();
const staged = [];
try {
for (const { path, contents } of replacements) {
staged.push({
destination: path,
backup: join(dirname(path), `.${basename(path)}.aera-${transactionId}.bak`),
temporary: await stageFile(path, contents, transactionId),
backupOwned: false,
replacementInstalled: false,
});
}
} catch (error) {
await Promise.all(staged.map((file) => file.temporary.cleanup()));
throw error;
}
try {
for (const file of staged) {
await rename(file.destination, file.backup);
file.backupOwned = true;
}
for (const file of staged) {
await rename(file.temporary.path, file.destination);
file.temporary.release();
file.replacementInstalled = true;
}
} catch (error) {
for (const file of staged.toReversed()) {
if (file.replacementInstalled) {
await rm(file.destination, { force: true }).catch(() => {});
file.replacementInstalled = false;
}
if (file.backupOwned) {
try {
await rename(file.backup, file.destination);
file.backupOwned = false;
} catch {}
}
}
throw error;
} finally {
await Promise.all(staged.map((file) => file.temporary.cleanup()));
}
for (const file of staged) {
await rm(file.backup);
file.backupOwned = false;
}
}
export async function syncVersion(directory = process.cwd()) {
const packagePath = join(directory, "package.json");
const manifestPath = join(directory, "manifest.json");
const versionsPath = join(directory, "versions.json");
const [packageJson, manifest, versions] = await Promise.all([
readJson(packagePath),
readJson(manifestPath),
readJson(versionsPath),
]);
const next = bumpMetadata(manifest, versions, packageJson?.version);
await replaceTogether([
{ path: manifestPath, contents: jsonContents(next.manifest) },
{ path: versionsPath, contents: jsonContents(next.versions) },
]);
}
function singleLine(value) {
return String(value).replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
function isDirectRun() {
if (process.argv[1] === undefined) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(modulePath);
} catch {
return false;
}
}
if (isDirectRun()) {
try {
await syncVersion();
console.log("Theme metadata version synchronized");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`ERROR: ${singleLine(message)}`);
process.exitCode = 1;
}
}

129
tests/version-bump.test.mjs Normal file
View file

@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { bumpMetadata } from "../scripts/version-bump.mjs";
const manifest = {
name: "Aera",
version: "0.1.0",
minAppVersion: "1.12.7",
author: "Peter",
authorUrl: "https://example.com/peter",
};
const versions = { "0.1.0": "1.12.7" };
const repoRoot = dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const cliPath = join(repoRoot, "scripts/version-bump.mjs");
function createFixture(t, packageVersion) {
const directory = mkdtempSync(join(tmpdir(), "aera-version-bump-"));
writeFileSync(
join(directory, "package.json"),
`${JSON.stringify({ version: packageVersion }, null, 2)}\n`,
);
writeFileSync(
join(directory, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
writeFileSync(
join(directory, "versions.json"),
`${JSON.stringify(versions, null, 2)}\n`,
);
t.after(() => rmSync(directory, { recursive: true, force: true }));
return directory;
}
function runCli(cwd) {
return spawnSync(process.execPath, [cliPath], { cwd, encoding: "utf8" });
}
test("bumpMetadata updates the manifest and appends the versions mapping", () => {
assert.deepEqual(bumpMetadata(manifest, versions, "1.0.0"), {
manifest: { ...manifest, version: "1.0.0" },
versions: {
"0.1.0": "1.12.7",
"1.0.0": "1.12.7",
},
});
});
test("bumpMetadata does not mutate its inputs", () => {
const manifestInput = structuredClone(manifest);
const versionsInput = structuredClone(versions);
const originalManifest = structuredClone(manifestInput);
const originalVersions = structuredClone(versionsInput);
const result = bumpMetadata(manifestInput, versionsInput, "1.0.0");
assert.deepEqual(manifestInput, originalManifest);
assert.deepEqual(versionsInput, originalVersions);
assert.notStrictEqual(result.manifest, manifestInput);
assert.notStrictEqual(result.versions, versionsInput);
});
test("bumpMetadata rejects invalid target versions", () => {
for (const target of ["1.0", "01.0.0", "v1.0.0", "", null]) {
assert.throws(
() => bumpMetadata(manifest, versions, target),
/target version must use x\.y\.z/,
);
}
});
test("bumpMetadata rejects invalid manifest and versions inputs", () => {
for (const invalidManifest of [null, [], { ...manifest, name: "Other" }]) {
assert.throws(
() => bumpMetadata(invalidManifest, versions, "1.0.0"),
/manifest\.json/,
);
}
for (const invalidVersions of [null, [], { "0.1.0": "1.12" }]) {
assert.throws(
() => bumpMetadata(manifest, invalidVersions, "1.0.0"),
/versions\.json/,
);
}
});
test("version bump CLI syncs metadata to package.json version", (t) => {
const directory = createFixture(t, "1.0.0");
const result = runCli(directory);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "Theme metadata version synchronized\n");
assert.equal(
readFileSync(join(directory, "manifest.json"), "utf8"),
`${JSON.stringify({ ...manifest, version: "1.0.0" }, null, 2)}\n`,
);
assert.equal(
readFileSync(join(directory, "versions.json"), "utf8"),
`${JSON.stringify({ ...versions, "1.0.0": "1.12.7" }, null, 2)}\n`,
);
});
test("version bump CLI reports invalid metadata without modifying files", (t) => {
const directory = createFixture(t, "1.0");
const manifestPath = join(directory, "manifest.json");
const versionsPath = join(directory, "versions.json");
const originalManifest = readFileSync(manifestPath, "utf8");
const originalVersions = readFileSync(versionsPath, "utf8");
const result = runCli(directory);
assert.equal(result.status, 1);
assert.match(result.stderr, /^ERROR: target version must use x\.y\.z\n$/);
assert.doesNotMatch(result.stderr, /\n\s+at /);
assert.equal(readFileSync(manifestPath, "utf8"), originalManifest);
assert.equal(readFileSync(versionsPath, "utf8"), originalVersions);
});

71
tests/workflows.test.mjs Normal file
View file

@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
const repoRoot = new URL("../", import.meta.url);
async function read(path) {
return readFile(new URL(path, repoRoot), "utf8");
}
test("README documents Aera development and release essentials", async () => {
const readme = await read("README.md");
assert.match(readme, /^# Aera$/m);
assert.match(readme, /A quiet interface for clear thinking\./);
assert.match(readme, /Chinese-first/i);
assert.match(readme, /!\[[^\]]*Aera[^\]]*\]\(screenshot\.png\)/i);
for (const command of [
"npm install",
"npm run build",
"npm run check",
"npm run link:vault",
"npm run dev",
]) {
assert.match(readme, new RegExp(`\\b${command.replaceAll(" ", "\\s+")}\\b`));
}
assert.match(readme, /AERA_TEST_VAULT/);
assert.match(readme, /manifest[^\n]*restart/i);
assert.match(readme, /CSS[^\n]*auto(?:matically)?[^\n]*reload/i);
assert.match(readme, /manifest\.json[^\n]*theme\.css/);
assert.match(readme, /\[MIT\]\(LICENSE\)/);
});
test("checks workflow runs repository checks on pushes and pull requests", async () => {
const workflow = await read(".github/workflows/checks.yml");
assert.match(workflow, /^name: Checks$/m);
assert.match(workflow, /^on:\s*\[push, pull_request\]$/m);
assert.match(workflow, /^permissions:\n contents: read$/m);
assert.match(workflow, /actions\/checkout@v4/);
assert.match(workflow, /actions\/setup-node@v4/);
assert.match(workflow, /node-version: 24/);
assert.match(workflow, /cache: npm/);
assert.match(workflow, /run: npm ci/);
assert.match(workflow, /run: npm run check/);
assert.match(workflow, /run: git diff --exit-code -- theme\.css/);
});
test("release workflow validates tags and creates a draft with theme assets", async () => {
const workflow = await read(".github/workflows/release.yml");
assert.match(workflow, /^name: Release Obsidian theme$/m);
assert.match(workflow, /^on:\n push:\n tags: \["\*"\]$/m);
assert.match(workflow, /^permissions:\n contents: write$/m);
assert.match(workflow, /actions\/checkout@v4/);
assert.match(workflow, /actions\/setup-node@v4/);
assert.match(workflow, /node-version: 24/);
assert.match(workflow, /cache: npm/);
assert.match(workflow, /run: npm ci/);
assert.match(workflow, /run: npm run check/);
assert.match(
workflow,
/node scripts\/check-theme\.mjs --release-tag "\$GITHUB_REF_NAME"/,
);
assert.match(workflow, /run: git diff --exit-code -- theme\.css/);
assert.match(workflow, /GH_TOKEN: \$\{\{ github\.token \}\}/);
assert.match(
workflow,
/gh release create "\$GITHUB_REF_NAME" --title "\$GITHUB_REF_NAME" --generate-notes --draft manifest\.json theme\.css/,
);
});