Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
}
118 changes: 118 additions & 0 deletions obsidian-plugin-release/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 <plugin> 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 <version>` — full ship

```bash
bun run scripts/release.ts <version> # via package.json script if user added it
# OR direct
~/.claude/skills/obsidian-plugin-release/scripts/release.ts <version>
```

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 `<version>` 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 <version>
```
8. **Push main** — `git push origin <current-branch>`.
9. **Tag** — `git tag -a <version> -m "Release <version>"` (annotated because `tag.gpgSign=true` is common; annotated works either way).
10. **Push tag** — `git push origin <version>`. 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 <version> --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/<id>`) + the deep link (`obsidian://show-plugin?id=<id>`) at the end.

### `release <version> --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=<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
65 changes: 65 additions & 0 deletions obsidian-plugin-release/references/changelog-style.md
Original file line number Diff line number Diff line change
@@ -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 <tag> --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.
173 changes: 173 additions & 0 deletions obsidian-plugin-release/references/scanner-warnings.md
Original file line number Diff line number Diff line change
@@ -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<void>;
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.
Loading