Reusable GitHub Actions workflows plus a small release toolkit for the Obsidian
plugin repos quickadd,
metaedit, and
podnotes.
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/.
A consumer references a workflow by path and pins a ref:
jobs:
ci:
uses: chhoumann/obsidian-plugin-workflows/.github/workflows/ci.yml@v1
with:
package-manager: pnpmPin @v1 (the moving major tag, see Versioning) for
automatic non-breaking updates, or a full commit SHA if you want to pin exactly
and let Dependabot bump it. 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/.
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 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.
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.
Validates the PR title as a Conventional Commit via
amannn/action-semantic-pull-request. 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) |
Newline-separated allowed commit types. |
The caller must trigger on pull_request_target, not pull_request: a fork
PR's title is only reliably readable, and GITHUB_TOKEN only carries the right
context, under pull_request_target. This is safe here because the workflow
never checks out or runs PR head code.
Example caller: templates/caller-workflows/pr-title.yml.
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)
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 pin @v4; 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.
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), app-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.
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-filesre-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.jsonhistory 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 -
attest-build-provenancesigns the assets; publish runsgh attestation verifyagainst 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].
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:
- 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 equalrelease-bot-app-slug. - Repository permissions: Contents: Read and write and Pull requests: Read and write. No account permissions, no webhook.
- Generate a private key (downloads a
.pem). - Install the App on the plugin repo only (Install App -> select the repo).
- In the repo: add a variable
RELEASE_APP_ID(Settings -> Secrets and variables -> Actions -> Variables) with the App's numeric id, and a secretRELEASE_APP_PRIVATE_KEYwith the full.pemcontents. - 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).
Before a repo's first real release, dispatch the Prepare release workflow
manually (Actions -> Prepare release -> Run workflow) with dry-run: true wired in
the stub, or temporarily uncomment # dry-run: true. The plan job computes the
version and notes and uploads them as an artifact; the open-PR job is skipped, so
nothing is pushed and no App call is made (dummy app-id / slug are fine for a
pure planning check). Confirm the planned version and notes look right, then remove
the dry-run.
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_targetclosed-event delivery for the release PR. The payload carries the merge-time SHA, and validate then dispatches the release stage on the exactrelease-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 therelease-policyin the release stub to the rules that were in force when it was cut.
- Ensure the current released version is tagged and has a published GitHub release (the planner requires a tagged baseline).
- Create and install the release-bot App; set
RELEASE_APP_ID+RELEASE_APP_PRIVATE_KEY(above). - Copy the three caller stubs from
templates/caller-workflows/(release-prepare.yml,release-trigger.yml,release.yml) into.github/workflows/. Setplugin-name(lowercase,[a-z0-9-]+),package-manager,release-policy,default-branch,node-version,release-bot-app-slug,release-assets(addstyles.cssif the plugin ships one), andverify-commandsto match the repo's scripts.- One pin: the
uses: ...@v4ref 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: thedefault-branch:input in all three, plus theif:gate inrelease-prepare.yml(head_branch == 'master') and inrelease-trigger.yml(base.ref == 'master'). Change all five.
- One pin: the
- Retire the repo's old release workflow(s), and make sure no branch is
literally named
releaseorrelease-run(old semantic-release setups often have a strayreleasebranch): git cannot hold bothrefs/heads/releaseandrefs/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. - Smoke-test with
dry-run: true, then push a conventional commit to the default branch and merge the release PR the bot opens.
Every third-party action is SHA-pinned with a version comment. These SHAs are kept fresh by Dependabot in the source repos and reused here verbatim:
| Action | Version | SHA |
|---|---|---|
step-security/harden-runner |
v2.19.4 | 9af89fc71515a100421586dfdb3dc9c984fbf411 |
actions/checkout |
v7.0.0 | 9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 |
pnpm/action-setup |
v6.0.9 | 0ebf47130e4866e96fce0953f49152a61190b271 |
actions/setup-node |
v6.4.0 | 48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e |
github/codeql-action/* |
v4 | 54f647b7e1bb85c95cddabcd46b0c578ec92bc1a |
actions/dependency-review-action |
v5.0.0 | a1d282b36b6f3519aa1f3fc636f609c47dddb294 |
amannn/action-semantic-pull-request |
v6.1.1 | 48f256284bd46cdaab1048c3721360e808335d50 |
actions/github-script |
v8.0.0 | ed597411d8f924073f98dfc5c65a23a2325f34cd |
actions/create-github-app-token |
v3.2.0 | bcd2ba49218906704ab6c1aa796996da409d3eb1 |
actions/upload-artifact |
v7.0.1 | 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a |
actions/download-artifact |
v8.0.1 | 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c |
actions/attest-build-provenance |
v4.1.1 | 0f67c3f4856b2e3261c31976d6725780e5e4c373 |
actions/setup-python |
v6.3.0 | ece7cb06caefa5fff74198d8649806c4678c61a1 |
rtCamp/action-slack-notify |
v2.4.0 | 33ca3be66c6f378fe1610fd1d5258632dbed5e58 |
Add this repo's github-actions ecosystem to each consumer's Dependabot so the
@v1/@v4/SHA pins here get bumped like any other action.
Copy-in files (not reusable workflows) in templates/, with a
per-file guide in templates/README.md:
version-bump.mjs- syncsmanifest.json+versions.jsonfor a manualnpm version/pnpm versionbump (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.caller-workflows/- minimal stubs consuming the reusable workflows, including the three release-pipeline stubs (release-prepare.yml,release-trigger.yml,release.yml).
Consumers pin a ref, so this repo carries a moving major tag.
- Tag releases
v1.0.0,v1.1.0, ... and keep av1tag pointed at the latest compatible release. Consumers pin@v1. - Move
v1forward on any backwards-compatible change:git tag -f v1 v1.2.0 && git push -f origin v1. - Cut
v2only for a breaking change to a workflow's input surface or behavior. Leavev1in place so unmigrated consumers keep working, then migrate them.
- Replace
.github/workflows/ci.ymlwith theci.ymlcaller stub; setpackage-manager,node-version, and any command overrides to match the repo's scripts. Keep triggers path-filter-free. - Replace
codeql.yml,dependency-review.yml, andpr-title.ymlwith their caller stubs. Keep the CodeQLschedule:cron and the PR-titlepull_request_targettrigger. - Confirm
version-bump.mjsmatchestemplates/version-bump.mjsand the"release"block matchestemplates/semantic-release.jsonc(adjust the lockfile asset andstyles.csspresence). - Align
.github/dependabot.ymlwithtemplates/dependabot.yml, and add agithub-actionsentry so the new@v1pins get updated. - 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).