mirror of
https://github.com/def-peter/obsidian-aera-theme.git
synced 2026-07-22 05:00:27 +00:00
ci: add checks and release automation
This commit is contained in:
parent
cbea428d5c
commit
e0a82bd7b1
7 changed files with 448 additions and 0 deletions
16
.github/workflows/checks.yml
vendored
Normal file
16
.github/workflows/checks.yml
vendored
Normal 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
23
.github/workflows/release.yml
vendored
Normal 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
24
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
|
@ -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
184
scripts/version-bump.mjs
Normal 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
129
tests/version-bump.test.mjs
Normal 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
71
tests/workflows.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue