From 4aa6f8b220a9f807ad5c947f7ab713824497ee0b Mon Sep 17 00:00:00 2001 From: Railly Date: Tue, 12 May 2026 18:23:11 -0500 Subject: [PATCH] feat: add obsidian-plugin-release skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atomic release flow for Obsidian community plugins. Bumps manifest.json + package.json + versions.json in a single transaction, builds, lints, signs an annotated tag, and triggers a GitHub Actions workflow that publishes the release with build-provenance attestation (SLSA in-toto). Battle-tested against the new Obsidian Community automated review (launched 2026-05-12). Ships with: - scripts/release.ts — full ship, --dry, --check, scaffold-workflow - templates/release.yml — workflow with attestations/v2 - templates/eslint.config.mjs — scanner-friendly config with correct ignores - references/scanner-warnings.md — cheat sheet for common findings + fixes - references/changelog-style.md — commit + release notes conventions Use when releasing any Obsidian plugin or scaffolding a release workflow into an existing one. --- README.md | 9 + marketplace.json | 8 + obsidian-plugin-release/SKILL.md | 118 ++++++++ .../references/changelog-style.md | 65 +++++ .../references/scanner-warnings.md | 173 ++++++++++++ obsidian-plugin-release/scripts/release.ts | 262 ++++++++++++++++++ .../templates/eslint.config.mjs | 56 ++++ obsidian-plugin-release/templates/release.yml | 50 ++++ 8 files changed, 741 insertions(+) create mode 100644 obsidian-plugin-release/SKILL.md create mode 100644 obsidian-plugin-release/references/changelog-style.md create mode 100644 obsidian-plugin-release/references/scanner-warnings.md create mode 100755 obsidian-plugin-release/scripts/release.ts create mode 100644 obsidian-plugin-release/templates/eslint.config.mjs create mode 100644 obsidian-plugin-release/templates/release.yml diff --git a/README.md b/README.md index 1887892..4a21809 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ npx skills add crafter-station/skills --skill spoti-cli -g # Install supply-chain-audit npx skills add crafter-station/skills --skill supply-chain-audit -g + +# Install obsidian-plugin-release +npx skills add crafter-station/skills --skill obsidian-plugin-release -g ``` Works with Claude Code, Cursor, Copilot, and [10+ more agents](https://github.com/vercel-labs/add-skill#available-agents). @@ -50,6 +53,12 @@ Works with Claude Code, Cursor, Copilot, and [10+ more agents](https://github.co |-------|--------------| | [supply-chain-audit](./supply-chain-audit/) | Read-only scanner for npm/PyPI supply-chain compromise (Shai-Hulud 2.0, Mini Shai-Hulud / TeamPCP, Axios DPRK). Versioned IOC pack, three-phase scan, PASS/FAIL verdict, and 48h bake-period remediation | +### Developer Tools + +| Skill | What it does | +|-------|--------------| +| [obsidian-plugin-release](./obsidian-plugin-release/) | Atomic release flow for Obsidian community plugins. Bumps version across manifest.json + package.json + versions.json, builds, lints, signs an annotated tag, and triggers a GitHub Actions workflow that publishes the release with build-provenance attestation (SLSA in-toto). Passes the new Obsidian Community automated review. | + ## Contributing 1. Fork diff --git a/marketplace.json b/marketplace.json index f86b823..7ff3256 100644 --- a/marketplace.json +++ b/marketplace.json @@ -45,6 +45,14 @@ "author": "Railly Hugo", "category": "security", "keywords": ["security", "supply-chain", "npm", "pypi", "shai-hulud", "mini-shai-hulud", "teampcp", "axios", "tanstack", "iocs", "malware-scan", "audit", "forensics"] + }, + { + "name": "obsidian-plugin-release", + "description": "Release a new version of an Obsidian community plugin without forgetting steps. Bumps version atomically across manifest.json + package.json + versions.json, builds, lints, commits, signs an annotated tag, and lets a GitHub Actions workflow publish the release with build-provenance attestation (SLSA in-toto). Idempotent and dry-run-safe. Includes scaffold for the release workflow and an eslint config that passes the new Obsidian Community automated review.", + "path": "obsidian-plugin-release", + "author": "Railly Hugo", + "category": "developer-tools", + "keywords": ["obsidian", "plugin", "release", "ci", "github-actions", "attestation", "slsa", "provenance", "community-plugins", "automation"] } ] } diff --git a/obsidian-plugin-release/SKILL.md b/obsidian-plugin-release/SKILL.md new file mode 100644 index 0000000..8cd8d16 --- /dev/null +++ b/obsidian-plugin-release/SKILL.md @@ -0,0 +1,118 @@ +--- +name: obsidian-plugin-release +description: | + Release a new version of an Obsidian community plugin without forgetting steps. + Use when: (1) user says "release the plugin", "bump to X.Y.Z", "ship a + new Obsidian release", "tag a new plugin version", "publish to the obsidian + directory", (2) the working directory contains manifest.json + main.js + + versions.json (Obsidian plugin signature), (3) user wants to scaffold the + release workflow into an existing plugin (`scaffold-workflow` arg). + Handles version bump across manifest.json + package.json + versions.json, + build, lint, conventional commit, signed annotated tag, push, and lets the + GitHub Actions workflow publish the release with build-provenance attestation. + Idempotent and dry-run-safe. +--- + +# obsidian-plugin-release + +Releasing an Obsidian plugin has 12 manual steps that are easy to half-do (forget `versions.json`, push tag before main, sign assets without provenance). This skill collapses the flow into a single intent: pick a version → ship. + +## Detection + +The current directory is an Obsidian plugin if all of these exist at the root: + +- `manifest.json` with `id`, `version`, `minAppVersion` keys +- `versions.json` (the map `pluginVersion → minAppVersion`) +- `main.js` listed in `.gitignore` is fine — we rebuild it + +Refuse to run if any of those are missing. Suggest `scaffold-workflow` if `manifest.json` exists but `.github/workflows/release.yml` does not. + +## Commands + +### `release ` — full ship + +```bash +bun run scripts/release.ts # via package.json script if user added it +# OR direct +~/.claude/skills/obsidian-plugin-release/scripts/release.ts +``` + +Steps the script does (each one a hard gate — stop on failure): + +1. **Verify clean tree** — abort if uncommitted changes (unless `--allow-dirty`). +2. **Verify version is new** — `versions.json` must not already contain it. +3. **Bump version atomically** — write the same `` to `manifest.json`, `package.json`, and append `versions.json` keyed to current `minAppVersion`. Use `JSON.parse → mutate → write` (preserve trailing newline + tab indent that Obsidian expects). +4. **Build** — `bun run build`. Abort on non-zero. +5. **Lint** — `bunx eslint src/` (skip if user has no `src/` — JS-only plugins exist). Abort on errors. +6. **Stage** — `git add manifest.json package.json versions.json main.js styles.css`. Only those 5 files. +7. **Commit** — conventional message, no Co-Authored-By: + ``` + chore: release + ``` +8. **Push main** — `git push origin `. +9. **Tag** — `git tag -a -m "Release "` (annotated because `tag.gpgSign=true` is common; annotated works either way). +10. **Push tag** — `git push origin `. This triggers the release workflow. +11. **Wait for workflow** — `gh run watch --exit-status` against the latest run on this tag. Abort if it fails. +12. **Verify release** — `gh release view --json assets` must include `main.js`, `manifest.json`, `styles.css`. Attestation is verified via `gh attestation verify` on `main.js`. + +Print the release URL + the community page (`https://community.obsidian.md/plugins/`) + the deep link (`obsidian://show-plugin?id=`) at the end. + +### `release --dry` + +Print every file that would change, the commit message, the tag message, and the workflow that would fire. No writes, no network calls. + +### `release --check` + +Audit current state. Output: + +- Last released version (from `versions.json`) +- Current `manifest.json` version (warn if ahead of `versions.json`) +- Git tree clean? Branch? Ahead/behind origin? +- Build passes locally? +- Lint passes locally? +- Workflow file present? Has attestation step? + +Use this before running a release if anything feels off. + +### `release scaffold-workflow` + +Drop `.github/workflows/release.yml` into the current plugin. Idempotent — refuses to overwrite unless `--force`. + +Template lives at [templates/release.yml](templates/release.yml). It: + +- Triggers on `[0-9]+.[0-9]+.[0-9]+` semver tags +- Sets up Bun with frozen lockfile +- Builds via `bun run build` +- Generates a build-provenance attestation over `main.js`, `manifest.json`, `styles.css` using `actions/attest-build-provenance@v2` +- Creates or updates the release with the 3 signed assets +- Required permissions: `contents: write`, `id-token: write`, `attestations: write` + +Also offer to scaffold [templates/eslint.config.mjs](templates/eslint.config.mjs) if the user is starting from scratch — it ships with the obsidianmd plugin pre-wired and the ignore globs (`web/**`, `assets/**`, `scripts/**`) that match the Obsidian scanner expectations. + +## Edge cases + +- **`tag.gpgSign=true`**: always use `git tag -a -m "..."` to avoid the "no tag message" error. Annotated tags work whether signing is enabled or not, so we use them unconditionally. +- **No `bun` available**: fall back to `npm run build` + `npx eslint src/`. The released workflow uses Bun regardless — local dev tool is incidental. +- **`web/` subdir present**: the scanner used to lint `web/**` and flag the Next.js landing site as a million unsafe-returns. Make sure `eslint.config.mjs` has `web/**` in `ignores` and the ignores block sits **before** `obsidianmd.configs.recommended` (the obsidianmd config spreads `files: ["**/*.ts", "**/*.tsx"]` which otherwise picks `web/` up). See [references/scanner-warnings.md](references/scanner-warnings.md). +- **Plugin not yet listed on Obsidian Community**: the workflow still runs. The release exists, the attestation exists, the deep link `obsidian://show-plugin?id=` only works once Obsidian's directory crawler picks it up (≤24h after submission). +- **First release ever**: the script accepts `--first` and skips the "version is new" check. Useful when bumping from `0.0.0` to `0.1.0` for the first submission. + +## Anti-patterns (don't) + +- **Don't bypass the tag**. The workflow is the source of truth for attestation. Manual `gh release create` skips the OIDC signing step and the Obsidian scanner will flag missing provenance. +- **Don't squash `versions.json`**. It's a permanent ledger. Appending only. +- **Don't push tag before main**. Workflow checks out the tag commit; if main isn't there yet, the build references commits that don't exist on the default branch. +- **Don't release from a dirty tree**. The committed `main.js` must match the built `main.js` from the source. If they diverge, `build-provenance` won't fail but reproducibility (`Build verified: byte-for-byte`) will. + +## Files + +- [scripts/release.ts](scripts/release.ts) — main entry, runs the 12 steps +- [templates/release.yml](templates/release.yml) — GitHub Actions release workflow +- [templates/eslint.config.mjs](templates/eslint.config.mjs) — Obsidian scanner-friendly eslint config +- [references/scanner-warnings.md](references/scanner-warnings.md) — common Obsidian scanner findings + how to fix +- [references/changelog-style.md](references/changelog-style.md) — recommended commit + release notes format + +## Related skills + +- `skill-creator` — for authoring new skills (this one was made with it) +- `skillkit` — local analytics for skill usage diff --git a/obsidian-plugin-release/references/changelog-style.md b/obsidian-plugin-release/references/changelog-style.md new file mode 100644 index 0000000..3773713 --- /dev/null +++ b/obsidian-plugin-release/references/changelog-style.md @@ -0,0 +1,65 @@ +# Changelog + commit style for Obsidian plugins + +## Commit message for releases + +Use conventional commits. One commit per version bump, never amend a prior commit. + +``` +chore: release 0.7.4 + +Round 2 of Obsidian scanner fixes: +- Timer functions: window.setTimeout/clearTimeout per scanner guidance +- utils/shell.ts: window.require instead of globalThis.require +- Rename options.global -> options.globalInstall (avoid false positive) +- CSS: remove !important via specificity bumps, merge duplicates + +Release workflow: +- .github/workflows/release.yml signs assets with GitHub OIDC attestation +``` + +Subject line ≤ 70 chars. Body wraps at ~80, bullets group related changes. + +## Tag + +Always annotated. `tag.gpgSign=true` is common in dev setups and refuses +lightweight tags with "no tag message": + +``` +git tag -a 0.7.4 -m "Release 0.7.4 — scanner cleanup round 2" +``` + +## GitHub release notes + +The workflow uses `gh release create ... --generate-notes` which pulls the +"Generated by GitHub" PR/commit list. That's fine for the public-facing +release. If you want richer notes: + +```bash +gh release edit --notes-file CHANGELOG.md +``` + +Or pass `--notes` inline with a HEREDOC. + +## In-app changelog (if you ship one in the plugin) + +Keep a `CHANGELOG.md` at the repo root in Keep-a-Changelog format. Each +release section header is the version: + +```markdown +## 0.7.4 — 2026-05-12 + +### Added +- GitHub Actions release workflow with build provenance attestation + +### Fixed +- Timer functions now use `window.setTimeout` per scanner guidance +- CSS: removed all `!important` rules + +### Changed +- `options.global` renamed to `options.globalInstall` for linter compatibility +``` + +If your landing site renders the changelog (e.g. `web/app/changelog/page.tsx`), +keep the in-app and the GitHub release notes in sync. The release script +doesn't currently sync these — it's the one manual step left after the +automated release. diff --git a/obsidian-plugin-release/references/scanner-warnings.md b/obsidian-plugin-release/references/scanner-warnings.md new file mode 100644 index 0000000..65a5c3f --- /dev/null +++ b/obsidian-plugin-release/references/scanner-warnings.md @@ -0,0 +1,173 @@ +# Obsidian scanner — common warnings + fixes + +The Obsidian Community automated review system (launched 2026-05-12) lints every plugin release via `eslint-plugin-obsidianmd` and a build-provenance checker. The full source of truth is https://github.com/obsidianmd/eslint-plugin — this file is a cheat sheet for the warnings you'll hit on a real plugin and the canonical fix. + +## Risk-level findings (block "Add to Obsidian") + +### `minAppVersion` lower than required API + +Workspace methods like `revealLeaf`, certain `Editor` APIs, and `Platform.isWayland` need a minimum Obsidian version. The scanner cross-checks every API call against the `minAppVersion` in `manifest.json`. + +Fix: bump `minAppVersion` in `manifest.json` AND add the bump to `versions.json`: + +```json +{ + "0.7.4": "1.7.2" +} +``` + +## Warnings (don't block install but hurt scorecard) + +### `Use 'window.setTimeout()' instead of 'activeWindow.setTimeout()'` + +`activeWindow` is for DOM operations on the currently focused popout (`activeWindow.document.createElement`, etc). Timer functions go on `window`. + +```ts +// wrong +activeWindow.setTimeout(() => ..., 10); +activeWindow.clearTimeout(timerId); + +// right +window.setTimeout(() => ..., 10); +window.clearTimeout(timerId); +``` + +### `Avoid using 'global' / 'globalThis'` + +The scanner can't tell `options.global` (a property name) from the JS `global` object — it flags either. Two paths: + +- **Property name collision**: rename it. `options.global` → `options.globalInstall`. +- **`globalThis.require` for Electron**: use `window.require` instead. + +```ts +const req = (window as unknown as { require?: (id: string) => unknown }).require; +``` + +### `Use 'createSvg("svg")' instead of 'document.createElementNS(...)'` + +Obsidian exposes `createSvg` and `createEl` as globals. They handle popout window context automatically. + +```ts +// wrong +const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); +const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); + +// right +const svg = createSvg("svg"); +const polyline = createSvg("polyline"); +``` + +### `Use 'activeDocument' instead of 'document' for popout window compatibility` + +Document references that don't target the main window must use `activeDocument`. This applies to `addEventListener`, `body.classList`, querySelector at the document level. + +```ts +// wrong +document.addEventListener("click", handler); +document.body.classList.contains("theme-dark"); + +// right +activeDocument.addEventListener("click", handler); +activeDocument.body.classList.contains("theme-dark"); +``` + +### `Unsafe return / member access / call of a value of type any` + +The scanner runs `typescript-eslint` with `recommendedTypeChecked`. All `JSON.parse`, third-party API responses (`requestUrl().json`), and dynamic imports are typed `any` by default. Cast at the seam: + +```ts +// wrong +const data = JSON.parse(raw); +return data.skills; + +// right +interface SearchResponse { skills?: Skill[] } +const data = JSON.parse(raw) as SearchResponse; +return data.skills ?? []; +``` + +### `Unsafe call of a type that could not be resolved` on electron `shell` + +Direct `import { shell } from "electron"` doesn't have types in the Obsidian env. Use a typed helper: + +```ts +// src/utils/shell.ts +interface ElectronShell { + openExternal(url: string): Promise; + showItemInFolder(fullPath: string): void; +} + +function getElectronShell(): ElectronShell | null { + try { + const req = (window as unknown as { require?: (id: string) => unknown }).require; + if (typeof req !== "function") return null; + const mod = req("electron") as { shell: ElectronShell } | undefined; + return mod?.shell ?? null; + } catch { + return null; + } +} + +export function openExternal(url: string): void { + const shell = getElectronShell(); + if (shell) { + void shell.openExternal(url); + return; + } + window.open(url, "_blank"); +} +``` + +### `Unused parameter` + +Prefix with underscore: `function foo(_unusedArg: string)`. Don't delete — keeps signature compat with callers. + +## CSS warnings + +### `Avoid !important` + +Subclassify or double-up the selector to win specificity: + +```css +/* wrong */ +.as-hidden { display: none !important; } + +/* right — double class wins by specificity */ +.as-container .as-hidden, +.as-hidden.as-hidden { display: none; } +``` + +### `Unexpected duplicate selector ".foo", first used at line N` + +Merge them. Don't redeclare. If two rules sit far apart for organization, consolidate or move them together. + +### `Unexpected unknown at-rule "@theme"` + +False positive for Tailwind v4. If it's in your landing site, make sure `web/**` is in the eslint ignores block of `eslint.config.mjs`. + +## Build verification + +The scanner reproduces your build byte-by-byte from source. To pass: + +- Don't commit a `main.js` that wasn't produced by `bun run build` against the current source +- Don't post-process `main.js` (no minifier outside esbuild, no manual edits) +- Lock your dependencies (`bun.lockb` or `package-lock.json` checked in) +- The workflow uses `bun install --frozen-lockfile` to match exactly + +## Artifact attestation + +Add `actions/attest-build-provenance@v2` to your release workflow with `id-token: write` and `attestations: write` permissions. The scanner shows a green `Pass — Build verified` once this lands. See [templates/release.yml](../templates/release.yml). + +## `web/`, `assets/`, `scripts/` flooding warnings + +The scanner walks the whole repo. If your plugin ships a landing site, marketing assets, or release scripts in subdirectories, they get linted too. Hoist an ignore block ABOVE the `obsidianmd.configs.recommended` spread: + +```js +export default defineConfig([ + { ignores: ["main.js", "node_modules/**", "*.mjs", "web/**", "assets/**", "scripts/**"] }, + ...obsidianmd.configs.recommended, + // ... +]); +``` + +Order matters — `obsidianmd.configs.recommended` adds `files: ["**/*.ts", "**/*.tsx"]`. If your ignores come after, they're applied to a smaller set; if they come before, they win. diff --git a/obsidian-plugin-release/scripts/release.ts b/obsidian-plugin-release/scripts/release.ts new file mode 100755 index 0000000..e9ef69d --- /dev/null +++ b/obsidian-plugin-release/scripts/release.ts @@ -0,0 +1,262 @@ +#!/usr/bin/env bun +/** + * obsidian-plugin-release — atomic release for Obsidian community plugins. + * + * Usage: + * release.ts # full ship + * release.ts --dry # show diff, no writes, no network + * release.ts --check # audit current state + * release.ts --allow-dirty + * release.ts --first # skip "version already in versions.json" guard + */ + +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; + +const cwd = process.cwd(); +const args = process.argv.slice(2); + +function die(msg: string): never { + console.error(`error: ${msg}`); + process.exit(1); +} + +function run(cmd: string, opts: { capture?: boolean; allowFail?: boolean } = {}): string { + const result = spawnSync("sh", ["-c", cmd], { + stdio: opts.capture ? "pipe" : "inherit", + encoding: "utf-8", + }); + if (result.status !== 0 && !opts.allowFail) { + die(`command failed: ${cmd}`); + } + return result.stdout?.trim() ?? ""; +} + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf-8")) as T; +} + +function writeJson(path: string, obj: unknown): void { + const raw = `${JSON.stringify(obj, null, "\t")}\n`; + writeFileSync(path, raw); +} + +interface Manifest { + id: string; + version: string; + minAppVersion: string; + [k: string]: unknown; +} + +function loadPluginState(): { + manifest: Manifest; + manifestPath: string; + versionsPath: string; + pkgPath: string | null; + versions: Record; +} { + const manifestPath = join(cwd, "manifest.json"); + const versionsPath = join(cwd, "versions.json"); + const pkgPath = join(cwd, "package.json"); + + if (!existsSync(manifestPath)) die("manifest.json not found — not an Obsidian plugin"); + if (!existsSync(versionsPath)) die("versions.json not found — not an Obsidian plugin"); + + const manifest = readJson(manifestPath); + if (!manifest.id || !manifest.version || !manifest.minAppVersion) { + die("manifest.json missing required keys (id, version, minAppVersion)"); + } + const versions = readJson>(versionsPath); + + return { + manifest, + manifestPath, + versionsPath, + pkgPath: existsSync(pkgPath) ? pkgPath : null, + versions, + }; +} + +function semverValid(v: string): boolean { + return /^\d+\.\d+\.\d+$/.test(v); +} + +function gitClean(): boolean { + const status = run("git status --porcelain", { capture: true }); + return status.length === 0; +} + +function currentBranch(): string { + return run("git rev-parse --abbrev-ref HEAD", { capture: true }); +} + +function hasWorkflow(): boolean { + return existsSync(join(cwd, ".github/workflows/release.yml")); +} + +function commandCheck(): void { + const state = loadPluginState(); + const lastVersion = Object.keys(state.versions).pop() ?? "(none)"; + const branch = gitClean() ? currentBranch() : `${currentBranch()} (dirty)`; + const workflow = hasWorkflow() ? "✓ present" : "✗ missing — run `scaffold-workflow`"; + + console.log(`plugin id: ${state.manifest.id}`); + console.log(`manifest version: ${state.manifest.version}`); + console.log(`last released: ${lastVersion}`); + console.log(`min app version: ${state.manifest.minAppVersion}`); + console.log(`branch: ${branch}`); + console.log(`workflow: ${workflow}`); + + if (state.manifest.version !== lastVersion && lastVersion !== "(none)") { + console.log(`\n⚠ manifest version (${state.manifest.version}) differs from versions.json head (${lastVersion}).`); + } +} + +function scaffoldWorkflow(force = false): void { + const target = join(cwd, ".github/workflows/release.yml"); + if (existsSync(target) && !force) die("release.yml already exists — pass --force to overwrite"); + + const templatePath = join(import.meta.dir, "../templates/release.yml"); + const template = readFileSync(templatePath, "utf-8"); + + const ghDir = join(cwd, ".github/workflows"); + run(`mkdir -p "${ghDir}"`); + writeFileSync(target, template); + console.log(`✓ wrote ${target}`); + console.log("commit and push, then tag a release to trigger the workflow."); +} + +function bumpFiles(state: ReturnType, version: string, dry: boolean): string[] { + const touched: string[] = []; + + const newManifest = { ...state.manifest, version }; + if (dry) { + console.log(`would write manifest.json: version ${state.manifest.version} → ${version}`); + } else { + writeJson(state.manifestPath, newManifest); + } + touched.push("manifest.json"); + + if (state.pkgPath) { + const pkg = readJson>(state.pkgPath); + if (dry) { + console.log(`would write package.json: version ${String(pkg.version)} → ${version}`); + } else { + writeJson(state.pkgPath, { ...pkg, version }); + } + touched.push("package.json"); + } + + const newVersions = { ...state.versions, [version]: state.manifest.minAppVersion }; + if (dry) { + console.log(`would append versions.json: "${version}": "${state.manifest.minAppVersion}"`); + } else { + writeJson(state.versionsPath, newVersions); + } + touched.push("versions.json"); + + return touched; +} + +function commandRelease(version: string, opts: { dry: boolean; allowDirty: boolean; first: boolean }): void { + if (!semverValid(version)) die(`invalid version "${version}" — expected MAJOR.MINOR.PATCH`); + + const state = loadPluginState(); + + if (!opts.first && state.versions[version]) { + die(`version ${version} already exists in versions.json`); + } + + if (!gitClean() && !opts.allowDirty) { + die("working tree dirty — commit or stash first, or pass --allow-dirty"); + } + + if (!hasWorkflow()) { + die(".github/workflows/release.yml missing — run `release scaffold-workflow` first"); + } + + console.log(`releasing ${state.manifest.id} ${state.manifest.version} → ${version}${opts.dry ? " (dry run)" : ""}`); + + const touched = bumpFiles(state, version, opts.dry); + + if (opts.dry) { + console.log("\nwould run:"); + console.log(" bun run build"); + console.log(" bunx eslint src/ # if src/ exists"); + console.log(` git add ${touched.join(" ")} main.js styles.css`); + console.log(` git commit -m "chore: release ${version}"`); + console.log(` git push origin ${currentBranch()}`); + console.log(` git tag -a ${version} -m "Release ${version}"`); + console.log(` git push origin ${version}`); + console.log(" gh run watch "); + return; + } + + console.log("\n→ building..."); + run("bun run build"); + + if (existsSync(join(cwd, "src"))) { + console.log("\n→ linting..."); + run("bunx eslint src/", { allowFail: false }); + } + + const stagedFiles = [...touched]; + if (existsSync(join(cwd, "main.js"))) stagedFiles.push("main.js"); + if (existsSync(join(cwd, "styles.css"))) stagedFiles.push("styles.css"); + + console.log("\n→ staging + committing..."); + run(`git add ${stagedFiles.map((f) => `"${f}"`).join(" ")}`); + run(`git commit -m "chore: release ${version}"`); + + const branch = currentBranch(); + console.log(`\n→ pushing ${branch}...`); + run(`git push origin ${branch}`); + + console.log(`\n→ tagging ${version}...`); + run(`git tag -a ${version} -m "Release ${version}"`); + run(`git push origin ${version}`); + + console.log("\n→ waiting for workflow..."); + // give Actions ~5s to pick up the tag push + run("sleep 5"); + const runId = run(`gh run list --workflow release.yml --limit 1 --json databaseId --jq '.[0].databaseId'`, { capture: true }); + if (runId) { + run(`gh run watch ${runId} --exit-status`); + } else { + console.log("⚠ could not find workflow run — check `gh run list` manually"); + } + + console.log("\n→ verifying release assets..."); + const assets = run(`gh release view ${version} --json assets --jq '[.assets[].name] | join(",")'`, { capture: true }); + console.log(`assets: ${assets}`); + + const id = state.manifest.id; + console.log("\n✓ release complete"); + console.log(` release: https://github.com/${run("gh repo view --json nameWithOwner --jq .nameWithOwner", { capture: true })}/releases/tag/${version}`); + console.log(` community: https://community.obsidian.md/plugins/${id}`); + console.log(` deeplink: obsidian://show-plugin?id=${id}`); +} + +// --- arg parsing --- +const flags = new Set(args.filter((a) => a.startsWith("--"))); +const positional = args.filter((a) => !a.startsWith("--")); + +if (positional[0] === "scaffold-workflow") { + scaffoldWorkflow(flags.has("--force")); +} else if (flags.has("--check") || positional[0] === "check") { + commandCheck(); +} else if (positional[0]) { + commandRelease(positional[0], { + dry: flags.has("--dry"), + allowDirty: flags.has("--allow-dirty"), + first: flags.has("--first"), + }); +} else { + console.log("usage:"); + console.log(" release full ship"); + console.log(" release --dry preview"); + console.log(" release --check audit current state"); + console.log(" release scaffold-workflow add .github/workflows/release.yml"); + process.exit(1); +} diff --git a/obsidian-plugin-release/templates/eslint.config.mjs b/obsidian-plugin-release/templates/eslint.config.mjs new file mode 100644 index 0000000..2d5faf9 --- /dev/null +++ b/obsidian-plugin-release/templates/eslint.config.mjs @@ -0,0 +1,56 @@ +import tsParser from "@typescript-eslint/parser"; +import { defineConfig } from "eslint/config"; +import obsidianmd from "eslint-plugin-obsidianmd"; + +export default defineConfig([ + { + ignores: [ + "main.js", + "node_modules/**", + "*.mjs", + "web/**", + "assets/**", + "scripts/**", + ], + }, + ...obsidianmd.configs.recommended, + { + files: ["src/**/*.ts"], + languageOptions: { + parser: tsParser, + parserOptions: { + project: "./tsconfig.json", + }, + globals: { + document: "readonly", + navigator: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", + DOMParser: "readonly", + NodeJS: "readonly", + process: "readonly", + Buffer: "readonly", + createEl: "readonly", + createSvg: "readonly", + activeWindow: "readonly", + activeDocument: "readonly", + window: "readonly", + }, + }, + rules: { + "import/no-extraneous-dependencies": ["error", { + devDependencies: true, + peerDependencies: true, + optionalDependencies: false, + }], + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-base-to-string": "error", + }, + }, +]); diff --git a/obsidian-plugin-release/templates/release.yml b/obsidian-plugin-release/templates/release.yml new file mode 100644 index 0000000..6f38629 --- /dev/null +++ b/obsidian-plugin-release/templates/release.yml @@ -0,0 +1,50 @@ +name: Release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + main.js + manifest.json + styles.css + + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" main.js manifest.json styles.css --clobber + else + gh release create "$TAG" main.js manifest.json styles.css \ + --title "$TAG" \ + --generate-notes + fi