Skip to content

About

Reusable GitHub Actions workflows and release templates for Obsidian plugin repos

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

obsidian-plugin-workflows

Reusable GitHub Actions workflows plus a small release toolkit for the Obsidian plugin repos quickadd, metaedit, and podnotes.

What and why

The three repos carried near-identical CI, CodeQL, dependency-review, and PR-title workflows plus the same release-support scripts. Kept as copies they drifted: one repo got a hardening fix, another a Node bump, a third an updated action SHA, and no single source of truth existed. This repo is that source of truth. Each plugin repo consumes the workflows via workflow_call and pins a version, so a fix lands once here and every consumer picks it up.

This repo carries two things: the reusable CI/quality workflows (ci.yml, codeql.yml, dependency-review.yml, pr-title.yml) and the shared forensic release pipeline (release-prepare.yml, release-validate.yml, release.yml plus the scripts/ release toolkit). All four plugin repos standardize on the same PR-to-release model; a consumer keeps only thin caller stubs and pins a ref. See Release pipeline and the design doc release-pipeline-plan.md.

A few genuinely copy-in bits (the version-bump.mjs used by manual bumps, the dependabot config) still live in templates/.

Consumption model

A consumer references a workflow by path and pins a ref:

jobs:
  ci:
    uses: chhoumann/obsidian-plugin-workflows/.github/workflows/ci.yml@<full-commit-sha> # v5.0.0
    with:
      package-manager: pnpm

Pin a full commit SHA with the release tag as a comment, never a bare tag. A tag can be moved by anyone who can push here, and every consumer would run the new code on its next run; a SHA changes only when the consumer merges a bump. Dependabot's github-actions ecosystem proposes those bumps from the version comment (see Versioning).

The consumer supplies the triggers (on: block); the reusable workflow supplies the jobs. Some triggers must live in the caller - see each workflow's notes below.

Ready-to-copy caller stubs are in templates/caller-workflows/.

Reusable workflows

ci.yml

Two jobs - Test and Build + Lint - each with harden-runner (egress audit), SHA-pinned actions, persist-credentials: false checkout, a package-manager setup with dependency caching, and a frozen-lockfile install (pnpm install --frozen-lockfile or npm ci).

Input Type Default Purpose
package-manager string pnpm pnpm or npm. Drives setup, cache, and install.
node-version string "24" Passed to actions/setup-node.
test-command string <package-manager> run test Command for the Test job.
build-command string <package-manager> run build Build step of the Build + Lint job.
lint-command string "" (skipped) Optional lint step after build.
check-commands string "" (skipped) Optional extra checks, one command per line (Svelte check, format check, type check).

Command defaults that reference package-manager are resolved at runtime because workflow_call input defaults cannot reference other inputs.

No path filters on the caller. Test and Build + Lint are meant to be required status checks. A required check gated behind a path filter never runs on a docs-only PR and stays pending forever, deadlocking merge. The caller stub triggers on every push/pull_request with no paths/paths-ignore.

Example caller: templates/caller-workflows/ci.yml.

codeql.yml

CodeQL analysis with the security-extended query suite across a language matrix.

Input Type Default Purpose
languages string '["javascript-typescript","actions"]' JSON array of CodeQL languages for the matrix.

The weekly cron must live in the caller. on.schedule is ignored inside a called workflow; the scheduled scan only fires from the caller's own schedule: trigger. The caller stub declares the cron (0 6 * * 1) alongside push/pull_request, and must grant security-events: write.

Example caller: templates/caller-workflows/codeql.yml.

dependency-review.yml

Reviews dependency changes on every PR and blocks newly-introduced advisories. Stricter than the action default (which only fails on runtime/high).

Input Type Default Purpose
fail-on-severity string moderate Minimum advisory severity that fails the check.
fail-on-scopes string runtime, development Dependency scopes enforced.

The caller triggers on pull_request with no path filters (so lockfile bumps are always scanned) and must grant pull-requests: write.

Example caller: templates/caller-workflows/dependency-review.yml.

pr-title.yml

Validates the PR title as a Conventional Commit header (<type>[(scope)][!]: <subject>) in a few lines of shell, reading the live title with a read-only token. The title becomes the squash-merge commit, which drives the semantic-release version - so a bad title can't produce a wrong or missing release.

Input Type Default Purpose
types string standard set (feat, fix, perf, docs, style, refactor, test, build, ci, chore, revert, release) Newline- or comma-separated allowed commit types.

The caller must trigger on pull_request_target, not pull_request: it runs the base branch's workflow definition, so a PR cannot edit away its own title check. This is safe here because the workflow never checks out or runs PR head code and only reads the PR.

Example caller: templates/caller-workflows/pr-title.yml.

Release pipeline

The forensic PR-to-release pipeline all four plugin repos share. Full design in release-pipeline-plan.md; this is the operator's guide.

The model. After every green push to the default branch, a per-repo GitHub App bot opens (or refreshes) exactly one standing release PR containing only the synchronized version files and generated notes - skipped when no conventional commits warrant a release. Merging that PR is the sole release act. The merge fires a forensic validator that binds the merged commit to the exact planned diff, then a publisher rebuilds, re-verifies, attests, and publishes the release.

push to default branch -> CI green
   -> release-prepare  (App bot opens/refreshes ONE draft release PR)
   -> maintainer reviews green checks, marks ready, squash-merges
   -> release-validate (full merged-PR forensics + recovery branch + dispatch)
   -> release          (resolve + build + tag + attest + publish + notify)

Reusable workflows and their triggers

workflow_run and pull_request_target cannot live in a called workflow, so each consumer keeps three thin caller stubs (in templates/caller-workflows/) that carry only the trigger + uses:; all logic lives in the reusables here.

Reusable Called from stub triggered by Consumer stub
release-prepare.yml workflow_run (CI completed) + workflow_dispatch escape hatch release-prepare.yml
release-validate.yml pull_request_target: [closed] + workflow_dispatch recovery release-trigger.yml
release.yml workflow_dispatch (dispatched by validate) release.yml

A caller stub sets the token ceiling with a top-level or per-job permissions: that grants the union its reusable needs; the reusable scopes each job down from there. Copy the stubs and keep the commit SHA pin; the toolkit scripts are checked out from the reusable workflow's own commit (job.workflow_repository / job.workflow_sha), so the uses: ref is the single version pin.

Inputs

Shared across all three reusables:

Input Default Purpose
plugin-name (required) Marker prefix: <plugin>-release-commit / <plugin>-release-pr.
package-name plugin-name Expected package.json name.
manifest-id plugin-name Expected manifest.json id.
default-branch master Branch release PRs target.
package-manager npm npm or pnpm; drives the release-file set (npm syncs package-lock.json, pnpm has no lockfile in the set).
release-bot-app-slug (required) App slug; the expected bot login is <slug>[bot].

release-prepare.yml adds: target-sha (empty = default-branch head), node-version, release-policy (below), client-id (a repo variable value), the release-app-private-key secret, and dry-run. release-validate.yml adds: pr-number, release-merger-login (empty = repo owner), and release-workflow (the consumer workflow to dispatch). release.yml adds: release-pr, node-version, release-policy, release-assets (JSON list), build-command, verify-commands (multiline), setup-python / python-version / docs-requirements, notify-name, and the optional slack-webhook / discord-webhook secrets.

release-policy is the commit-to-release classification: a JSON list of semantic-release releaseRules (e.g. [{"scope":"deps","type":"build","release":"patch"}]) applied on top of the standard conventional-commit rules (feat -> minor, fix/perf -> patch, breaking -> major). Pin it explicitly in both the release-prepare.yml and release.yml stubs, with the same value, so a toolkit ref bump can never change which commits cut a release; an empty value falls back to the toolkit default (kept for compatibility). The policy only decides the version bump - notes and the release diff are policy-independent - so a prepare/release mismatch either produces the identical release or fails closed on the release stage's expected-version recompute.

Security model - what each forensic check defends against

Everything after "maintainer clicks merge" is treated as adversarial until proven otherwise. The layered checks:

  • Standing PR provenance - PR author is the App bot, base is the default branch, head repo is this repo, branch is release/<semver>, title is the exact contract string. Blocks a human- or fork-authored PR impersonating a release.
  • Exact version-file diff - the PR changes exactly the release-file set; release-contract.mjs validate-files re-derives that only version fields moved. Blocks smuggling source or lockfile changes into a "version bump".
  • Commit-message contract + parent - the head commit message is the exact marker and its single parent is the recorded base. Blocks a rewritten or reparented release commit.
  • Squash-parent + tree-sha - the squash merge commit has one parent (the base) and its tree equals the validated head tree. Blocks a merge that altered content.
  • Prior-tag ancestry and history - the previous version tag exists, has valid synchronized metadata, is an ancestor of the base, its recorded versions.json history is byte-identical to the base, and the compatibility floor (minAppVersion) has not decreased. Blocks version-history forgery, silent history rewrites, a lowered floor slipped in between releases, and out-of-order releases.
  • Durable recovery branch release-run/<version> - pins the exact release SHA so a failed or re-run release recovers to the same commit; deleted only after a verified publish.
  • Attestation + post-publish verification - actions/attest signs the assets; publish runs gh attestation verify against the exact source digest, then downloads every remote asset and re-hashes it. Blocks tampered or swapped assets.

The version-file semantics themselves (synchronized versions, floor rules including the pending minAppVersion raise, append-only history) live only in scripts/release-contract.mjs: validate downloads the version files at the base, release, and previous-tag commits and runs the pinned contract script (validate-release), the same model prepare and the release stage use, so the workflows cannot drift from the contract.

The read-only forensic API calls in validate and resolve are wrapped in a bounded retry (up to four attempts on a transient 5xx) so a momentary API outage cannot strand a release the maintainer already merged; only reads are retried, never the recovery-branch or dispatch writes.

The App token is used only in prepare, to author the release commit and PR as the bot the maintainer controls. Tags and the GitHub release are created by the default GITHUB_TOKEN (branch protection guards branches, not tags), so the release object's author stays github-actions[bot].

Per-repo GitHub App setup

Each repo needs its own release-bot App so PR authorship binds to an identity you control and can be made a branch-protection bypass actor. Per repo:

  1. Create a GitHub App (Settings -> Developer settings -> GitHub Apps -> New). Name it e.g. <plugin> Release Bot; note the resulting slug (lowercased, spaces to hyphens, e.g. podnotes-release-bot) - it must equal release-bot-app-slug.
  2. Repository permissions: Contents: Read and write and Pull requests: Read and write. No account permissions, no webhook.
  3. Generate a private key (downloads a .pem).
  4. Install the App on the plugin repo only (Install App -> select the repo).
  5. In the repo: add a variable RELEASE_APP_CLIENT_ID (Settings -> Secrets and variables -> Actions -> Variables) with the App's Client ID (shown on the App's General page, e.g. Iv23li...), and a secret RELEASE_APP_PRIVATE_KEY with the full .pem contents.
  6. If the default branch is protected, add the App as an allowed bypass/merge actor as your ruleset requires (the maintainer still merges the PR; the App only authors it).

Dry-run smoke test

Before a repo's first real release, dispatch the Prepare release workflow manually (Actions -> Prepare release -> Run workflow) with dryRun checked. The stub forwards it as the reusable workflow's dry-run input. The plan job computes the version and notes and uploads them as an artifact, and a check job mints a read-only App token and confirms its slug matches release-bot-app-slug. The open-PR job is skipped, so nothing is pushed. Confirm the planned version and notes look right. Dispatch with dryRun again any time to preview the next release.

Recovery

Every stage is replayable from durable identities; nothing depends on transient workflow state.

  • A release run failed after the PR merged (or a validation bug was fixed in a newer toolkit pin): re-dispatch the Trigger release workflow from the default branch (Actions -> Trigger release -> Run workflow) with the merged release PR number. Validate re-derives everything from the PR, accepts the dispatch only from the current default-branch head, and fails closed on a bad number. The whole recovered pipeline - validate and the release stage it dispatches on the default branch - runs the stubs and pins currently on the default branch, so bump those first if the fix lives here; the release stage's trusted-branch-recovery check re-derives and re-verifies the exact release commit from the PR.
  • Webhook redelivery is the alternative when you want the run to carry the exact merge-time event identity: redeliver the original pull_request_target closed-event delivery for the release PR. The payload carries the merge-time SHA, and validate then dispatches the release stage on the exact release-run/<version> source, as on a fresh merge.
  • The release stage alone failed: the release-run/<version> recovery branch pins the exact release SHA until a verified publish; dispatch the consumer's Release workflow on it (or on the current default branch, to pick up newer pins) with the same PR number - or simply re-dispatch Trigger release as above. Re-running an already-published version is safe: the publisher verifies the existing release byte-for-byte and finishes without mutating it.
  • A release cut under an older release-policy (e.g. before chore commits stopped releasing) fails the release stage's plan recompute with "Release plan version is none" - fail-closed, since the current policy would never have produced that version. To rebuild such a release, temporarily set the release-policy in the release stub to the rules that were in force when it was cut.

Migration checklist (release pipeline)

  1. Ensure the current released version is tagged and has a published GitHub release (the planner requires a tagged baseline).
  2. Create and install the release-bot App; set RELEASE_APP_CLIENT_ID + RELEASE_APP_PRIVATE_KEY (above).
  3. Copy the three caller stubs from templates/caller-workflows/ (release-prepare.yml, release-trigger.yml, release.yml) into .github/workflows/. Set plugin-name (lowercase, [a-z0-9-]+), package-manager, release-policy, default-branch, node-version, release-bot-app-slug, release-assets (add styles.css if the plugin ships one), and verify-commands to match the repo's scripts.
    • One pin: the uses: ...@<sha> ref selects this repo's version for the workflow AND its toolkit scripts (checked out from the reusable workflow's own commit), so Dependabot SHA bumps are always self-consistent.
    • If the default branch is not master, the literal appears in five places across the three stubs: the default-branch: input in all three, plus the if: gate in release-prepare.yml (head_branch == 'master') and in release-trigger.yml (base.ref == 'master'). Change all five.
  4. Retire the repo's old release workflow(s), and make sure no branch is literally named release or release-run (old semantic-release setups often have a stray release branch): git cannot hold both refs/heads/release and refs/heads/release/<version>, so a stray branch blocks the pipeline's branch creation. Prepare and validate preflight this and fail with an actionable message, but deleting the stray up front saves a failed run.
  5. Smoke-test by dispatching Prepare release with dryRun checked, then push a conventional commit to the default branch and merge the release PR the bot opens.

Pinned action versions

Every third-party action is SHA-pinned with a version comment. This repo's Dependabot config keeps them fresh and holds back releases younger than 7 days:

Action Version SHA
step-security/harden-runner v2.22.1 ccd8616d44fd3846e67624a50d5aad6d37bf2d25
actions/checkout v7.0.1 3d3c42e5aac5ba805825da76410c181273ba90b1
pnpm/action-setup v6.1.0 ea17c68df8912ef543352723c149a84f56e3d413
actions/setup-node v7.1.0 949feb2413d6458794dcd2491c4babbbce0c15c1
github/codeql-action/* v4.39.0 6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee
actions/dependency-review-action v5.0.0 a1d282b36b6f3519aa1f3fc636f609c47dddb294
actions/github-script v9.0.0 3a2844b7e9c422d3c10d287c895573f7108da1b3
actions/create-github-app-token v3.3.0 64ab729a6294000f5aa4a4bc0b73d1f32bc45510
actions/upload-artifact v7.0.2 cf430e030ddbb5b0abf93d22962f4752f3646cd9
actions/download-artifact v8.0.2 9000827ccba6bdab643e8b6fd33ac0654aef8333
actions/attest v4.2.2 1e69f48acb82d1966a394da916b4c1698aa569d6
actions/setup-python v7.0.0 5fda3b95a4ea91299a34e894583c3862153e4b97

Add this repo's github-actions ecosystem to each consumer's Dependabot so the SHA pins on this repo's workflows get bumped like any other action.

Templates

Copy-in files (not reusable workflows) in templates/, with a per-file guide in templates/README.md:

  • version-bump.mjs - syncs manifest.json + versions.json for a manual npm version / pnpm version bump (the shared pipeline does this itself).
  • semantic-release.jsonc - legacy semantic-release plugin chain, kept for repos not yet on the shared release pipeline.
  • dependabot.yml - grouped weekly npm + github-actions updates with a 7-day cooldown.
  • caller-workflows/ - minimal stubs consuming the reusable workflows, including the three release-pipeline stubs (release-prepare.yml, release-trigger.yml, release.yml).

Versioning of this repo

Consumers pin a commit SHA with a version comment, so every release needs an immutable semver tag for that comment and for Dependabot to compare against.

  • Tag each release v5.0.0, v5.1.0, ... The "Immutable release tags" ruleset blocks moving or deleting any v*.*.* tag.
  • Keep the major tag (v5) pointed at the latest compatible release for anyone still on tag pins: git tag -f v5 v5.1.0 && git push -f origin v5.
  • Cut v6 only for a breaking change to a workflow's input surface or behavior. Leave v5 in place so unmigrated consumers keep working, then migrate them.

Migration checklist (CI and quality workflows)

  1. Replace .github/workflows/ci.yml with the ci.yml caller stub; set package-manager, node-version, and any command overrides to match the repo's scripts. Keep triggers path-filter-free.
  2. Replace codeql.yml, dependency-review.yml, and pr-title.yml with their caller stubs. Keep the CodeQL schedule: cron and the PR-title pull_request_target trigger.
  3. Confirm version-bump.mjs matches templates/version-bump.mjs and the "release" block matches templates/semantic-release.jsonc (adjust the lockfile asset and styles.css presence).
  4. Align .github/dependabot.yml with templates/dependabot.yml, and add a github-actions entry so the SHA pins get updated.
  5. Open a PR. Verify all four checks run and that required-check names still match your branch protection (the job names are Test, Build + Lint, Analyze (...), Dependency Review, Validate PR title).

About

Reusable GitHub Actions workflows and release templates for Obsidian plugin repos

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages