Reusable GitHub Actions workflows for dependency safety verification and release management.
- Opt-in release-age verification — Dependabot's native
cooldown.default-daysowns the wait before PR creation;release_age_policy(default"off") can additionally label (advisory) or fail the gate (blocking) on target versions younger thanminimum_release_age_days - Version-aware advisory filtering — advisories already patched at or below the PR's target version are collapsed into a non-blocking "historical" section
- GHSA + OSV dual-source scan — every package is queried against both GitHub Advisory and OSV.dev; mismatches surface both
- OpenSSF Scorecard integration — Scorecard results for each GitHub Action appear in the scan comment
- Update-or-create scan comments — a single stable comment per PR; change detection posts a top-level PR comment only when advisory IDs actually change
- Auto-merge by default — clean scans enable
gh pr merge --auto(setauto_merge: falseto opt out); dirty scans apply labels (security-review-needed,dependency-age-violation, ordependency-safety-error) instead - Grouped PR support — handles both single-package and grouped Dependabot PRs
- Reusable pre-commit autoupdate PRs — shared
pre-commit-autoupdate.ymlrunsuvx pre-commit autoupdate, opens a dependency PR only when the configured pre-commit config changes, recommends Release Bot App auth for required checks, and keeps a documentedGITHUB_TOKENfallback
- Dependabot configured for your repo (GitHub Actions, pip/uv, and/or npm ecosystems)
- Native cool-down configured in
.github/dependabot.yml(see Quick Start) - No Renovate — this workflow only scans
dependabot[bot]PRs; other actors are passed through with a success status (except external fork PRs, whose read-only token can't post the status — see Fork PRs and the required gate)
Scope: version-update PRs. Dependabot's native
cooldown:setting applies only to version updates, not security updates. With the defaultrelease_age_policy: "off"this distinction has no effect — the workflow performs no post-PR age checks. If you opt intoadvisoryorblocking, young security-fix PRs will be flagged (or blocked) even though native cooldown never held them — preferadvisoryif that trade-off is not acceptable for your repo.
The waiting period is owned by Dependabot itself — by default the workflow does not re-verify it (opt in with release_age_policy). Add cooldown: to .github/dependabot.yml:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 5
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "deps"
cooldown:
default-days: 5
groups:
npm-minor-patch:
update-types: ["minor", "patch"]See Dependabot cool-down docs for per-severity and per-ecosystem overrides.
One workflow file invokes the reusable verifier on every Dependabot PR:
# .github/workflows/dependency-safety.yml
name: Dependency Safety
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
statuses: write
issues: write
concurrency:
group: safety-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
safety:
uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v4
secrets: inheritauto_merge defaults to true and requires contents: write. If you grant only contents: read, set auto_merge: false.
Note:
@v4is the current floating major.@v3(release-age enforcement on by default, auto-merge opt-in) and@v2(last cooldown-bearing line) continue to work but receive no further updates. Releases in this repo are dispatched manually — see Versioning.
External fork PRs: by default GitHub gives
GITHUB_TOKENa read-only token on PRs from forks even when you declarestatuses: write— unless a repo admin has enabled Send write tokens to workflows from pull requests (docs). With a read-only token the reusable workflow cannot post thedependency-safety / gatestatus from the fork run — it logs a notice and the job stays green rather than failing. If you make that status required, see Fork PRs and the required gate.
pre-commit-autoupdate.yml is a workflow_call-only reusable workflow for
repos that keep a pre-commit config file (default path .pre-commit-config.yaml
via config_path). Callers keep their own schedule,
workflow_dispatch, and optional concurrency; the shared workflow installs
uv and runs uvx, so the caller repo does not need to be a uv-managed Python
project.
Prefer the App-token caller for repos with required checks:
name: Pre-commit Autoupdate
on:
schedule:
- cron: "0 8 * * 1"
workflow_dispatch:
permissions: {}
concurrency:
group: pre-commit-autoupdate
cancel-in-progress: true
jobs:
autoupdate:
permissions:
contents: read
uses: j7an/shared-workflows/.github/workflows/pre-commit-autoupdate.yml@v4
secrets:
RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}The caller repo must define vars.RELEASE_BOT_APP_ID. Without that var, the
workflow falls back to GITHUB_TOKEN; fallback callers must grant
contents: write and pull-requests: write, and their generated PRs may need
a close/reopen or empty commit to start required CI because of GitHub's
recursion guard.
pnpm-packagemanager-update.yml is a workflow_call-only reusable workflow
that keeps a repo's pinned packageManager field (in package.json, default
path) current against the pnpm releases published on the npm registry. It
reads the current [email protected][+algo.hex] pin, queries the full npm packument
for pnpm, selects the newest non-deprecated release in the same major that
clears the minimum release age, rewrites only the packageManager value, and
opens (or refreshes) a pull request carrying just that one-line change.
Callers keep their own schedule and workflow_dispatch triggers; the shared
workflow does the resolution, integrity verification, and PR management.
name: pnpm packageManager Update
on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:
permissions: {}
jobs:
update:
permissions:
contents: write
pull-requests: write
statuses: write
uses: j7an/shared-workflows/.github/workflows/pnpm-packagemanager-update.yml@v4
secrets: inheritThe job-level permissions: block above is mandatory, not decorative — the
caller job must grant the permission ceiling. The reusable workflow narrows
permissions per step, but a called workflow cannot grant itself permissions
the caller withheld.
| Input | Type | Default | Description |
|---|---|---|---|
manifest_path |
string | "package.json" |
Path to the package.json carrying the packageManager field. Must be relative, must not contain .., a newline, or a carriage return. A leading ./ is normalized away so the value matches what the pull-request files API reports |
minimum_release_age_days |
number | 5 |
A pnpm release must be at least this old before a PR is opened. Bypassed when the currently pinned version is deprecated. Set 0 to disable waiting — unlike dependency-safety.yml's input of the same name, this gates whether the PR is created at all and has no off switch |
branch |
string | "deps/pnpm-packagemanager" |
Pull request branch |
title |
string | "deps: update pnpm packageManager" |
Pull request title |
commit_message |
string | "deps: update pnpm packageManager" |
Commit message |
labels |
string | "dependencies" |
Labels passed to create-pull-request |
sign_commits |
boolean | true |
Whether create-pull-request signs commits |
Like pre-commit-autoupdate.yml, this workflow prefers a GitHub App token
(vars.RELEASE_BOT_APP_ID + secrets.RELEASE_BOT_PRIVATE_KEY) and falls back
to GITHUB_TOKEN when neither is configured. Configuring only
vars.RELEASE_BOT_APP_ID is a hard failure, not a fallback: a repo that set it
asked for App-authored PRs, and quietly opening GITHUB_TOKEN-authored ones
instead would leave required CI unstarted with nothing to notice. A private key
arriving through secrets: inherit with no repo-level RELEASE_BOT_APP_ID is
treated as an org-wide secret this repo never opted into, so it stays a
fallback rather than an error.
The fallback is degraded: a PR opened with GITHUB_TOKEN runs under GitHub's
recursion guard, which can prevent required CI from starting on it, so a repo
with required status checks should provision the App rather than rely on the
fallback. On the fallback path the opened PR carries a note saying so. See
Release Bot App setup for provisioning.
Version selection never crosses a major boundary — only releases within the currently pinned major are considered. A major bump changes pnpm's own behavior in ways this workflow cannot safely evaluate unattended, so it is deliberately left to a human. If the pinned major has no eligible release (e.g. it's deprecated with nothing left to upgrade to within it), the workflow fails the step with an explicit error rather than silently picking a different major.
If the currently pinned version is marked deprecated on the npm registry, the
minimum_release_age_days wait is bypassed for the replacement: staying on a
known-broken toolchain is a present, confirmed problem, while the wait guards
against a hypothetical one. The opened PR says so explicitly and quotes the
registry's deprecation notice verbatim in a blockquote. That notice is
registry-controlled prose, so < is escaped before it reaches the body:
blockquoting stops Markdown but not raw HTML, and an <img> in a deprecation
message would otherwise render in the PR and leak a viewer's IP and
User-Agent.
When the existing pin carries a +<algo>.<hex> integrity suffix, the
workflow verifies the downloaded pnpm tarball against the npm registry's own
dist.integrity claim before computing the new suffix, and fails closed on a
mismatch. Because dist.tarball and dist.integrity come from the same
document, that check alone proves nothing about origin, so the tarball URL's
host must be registry.npmjs.org over HTTPS before anything is fetched. If the
registry publishes no dist.integrity at all, the run continues but emits a
::warning:: annotation saying the recorded digest is unverified. The suffix
body is the digest of the downloaded tarball bytes
themselves (not an extracted file) — this matches Corepack's own contract,
confirmed by round-tripping corepack use pnpm@<version> and comparing
Corepack's suffix, openssl dgst over the tarball, and the registry's
dist.integrity decoded to hex. The workflow preserves whichever hash
algorithm the existing pin already declares — it never chooses one. When the
current pin carries no integrity suffix, the new pin is written the same way:
plain [email protected] with no suffix. Integrity verification only runs when a
suffix is already present to preserve; it is not added on a pin's first
update.
| Input | Type | Default | Description |
|---|---|---|---|
enable_scorecard |
boolean | true |
Include OpenSSF Scorecard results for GitHub Actions in the scan comment |
auto_merge |
boolean | true |
On clean scans, enable gh pr merge --auto (requires contents: write); on dirty scans, apply the appropriate label. Set false for manual merges |
release_age_policy |
string | "off" |
Post-PR release-age verification: off (no age lookup), advisory (label + comment on young targets, gate stays green, auto-merge suppressed), blocking (gate fails). Quote "off" in YAML |
minimum_release_age_days |
number | 5 |
Threshold used when release_age_policy is advisory or blocking; ignored when off. Should match cooldown.default-days in dependabot.yml |
| Manifest / lockfile | Dependency rows | Transitive sweep | Release age | Scorecard |
|---|---|---|---|---|
.github/workflows/*.yml (uses: lines) |
GHSA ACTIONS, OSV GitHub Actions |
— | GitHub Releases API | Yes |
requirements*.txt, uv.lock, poetry.lock, pyproject.toml |
GHSA PIP, OSV PyPI |
— | PyPI JSON API | No |
package.json + pnpm-lock.yaml |
GHSA NPM, OSV npm |
OSV batch over new lockfile entries | deps.dev v3 | No |
package-lock.json |
Not supported — fails closed | — | — | — |
yarn.lock |
Not supported — fails closed | — | — | — |
Dependabot calls the ecosystem npm regardless of which package manager a repo
uses, so the table is keyed by lockfile. A repo using npm or Yarn directly will
get an error gate with a diagnostic naming the unsupported file — not a
silent pass.
Two claims are proven per PR:
- Declared dependencies — every
package.jsonchange is corroborated againstpnpm-lock.yaml'simporters:/catalogs:sections, and the resolved version from the lockfile (never the manifest range) is scanned through GHSA, OSV, and release-age. - Transitive versions — every
name@versionnewly added under the lockfile'spackages:section is checked against OSV in batched queries of up to 100 packages each. These do not participate in release-age policy, because a transitive package nobody chose being days old is not a signal about the update.
Anything the parser cannot account for — an unrecognized package.json key, a
lifecycle script, a lockfile overrides: or pnpmfileChecksum: change, an
unsupported lockfileVersion, a manifest change with no lockfile
counterpart, or a diff that introduces more than 1000 new transitive lockfile
package versions (the tier-2 sweep's per-PR cap) — fails closed with a
diagnostic.
Limitations. Scorecard is not reported for npm packages: the registry's
repository URL is self-declared by the publisher and monorepo packages collapse
to a single repo, so a score would be misattributed. A packageManager change
is not yet a recognized shape and fails closed. Repositories on a pnpm major
outside Dependabot's documented support may receive no PRs at all; that is a
Dependabot behavior, not a gate behavior.
Grouped Dependabot PRs (multiple packages in one PR) are supported — each package is scanned independently and results are merged into one comment.
Target versions are resolved per ecosystem. For GitHub Actions and Python they come from inline # vX.Y.Z comments, falling back to the Dependabot PR body (Bumps [pkg] from A to B) when absent. For npm/pnpm they come from the lockfile's resolved versions; the PR body is used only as a last resort when the lockfile parser proves nothing, a state that already fails the gate — see the npm/pnpm scanning model above.
Dependabot's native cooldown holds an update for `cooldown.default-days`
│
▼
Dependabot opens the PR (target version is now ≥ cooldown days old)
│
▼
dependency-safety.yml fires on pull_request
├── Non-dependabot PR? → status "success" (no-op; external forks can't post — see "Fork PRs and the required gate")
├── Status → "pending" ("Scanning dependencies for safety...")
├── Parses diff to extract package names + target versions
│ ├── github-actions / pip: inline versions, falling back to PR body text
│ └── npm/pnpm: lockfile-resolved versions; PR-body fallback only when
│ the parser proves nothing
├── npm/pnpm only — Tier 2 sweep of newly introduced lockfile entries
│ ├── Batched OSV queries, up to 100 packages per request
│ └── Advisories suppress auto-merge; release age is NOT checked for
│ Tier 2 (transitive versions are not declared by the PR author)
├── Verifies release age (only when release_age_policy is advisory or blocking; blocking fails the gate, advisory labels + suppresses auto-merge)
├── For each package:
│ ├── GHSA GraphQL query (by ecosystem)
│ ├── OSV.dev POST query (with version if known)
│ └── OpenSSF Scorecard (github-actions only, if enabled)
├── Version-aware filter:
│ ├── Advisories with firstPatchedVersion ≤ target → historical bucket
│ └── Advisories affecting target version → blocking bucket
├── Computes deterministic verdict (safety-verdict.sh)
├── Reconciles labels (security-review-needed, dependency-age-violation, dependency-safety-error)
├── Update-or-create single scan comment
├── If advisory IDs changed since last scan → post change-notification top-level PR comment
├── If clean and auto_merge=true (default) → gh pr merge --auto
└── Sets final gate status (success / failure / error)
PR #23 added filtering so that advisories Dependabot has already fixed don't block the PR:
- If GHSA reports
firstPatchedVersionand the target version is ≥ that value, the advisory is moved into a collapsed<details>block labeled "historical advisory/ies (patched at or before target version — not blocking)". - Only advisories affecting the target version count toward the blocking total.
- When the target version can't be determined (no inline comment and no match in the PR body), the workflow falls back to reporting all advisories for the package — safer default.
The dependency-safety / gate commit status uses three states:
| State | When |
|---|---|
success |
Clean scan, OR advisories present (label security-review-needed), OR age violation under release_age_policy: advisory (label dependency-age-violation) |
failure |
Age violation under release_age_policy: blocking |
error |
Dependency extraction failed, or GHSA/OSV/age-lookup APIs errored — the verdict is unreliable; manual review required |
Labels:
| Label | Color | Applied when | Removed when |
|---|---|---|---|
security-review-needed |
red (B60205) |
Advisory scan finds vulnerabilities affecting target versions | Re-scan finds zero applicable advisories AND no error state |
dependency-age-violation |
amber (FBCA04) |
Any target version is younger than minimum_release_age_days (only under release_age_policy: advisory or blocking) |
All versions pass age check AND no error state |
dependency-safety-error |
grey (6E7781) |
Scan extraction failed or API errors occurred | Clean scan completes without errors |
Reconciliation is authoritative when the scan succeeds. On the error path, labels are preserved (not removed) since the verdict is unreliable.
dependency-safety.yml is a Dependabot-automation gate. Repos that make
dependency-safety / gate required need exactly one trusted writer for every
pull request head SHA. The correct companion depends on how your scanner caller
is gated.
Recommended matched pair: Dependabot-gated scanner plus non-bot gate. This
is the cleanest shape for repos that require dependency-safety / gate on all
PRs: Dependabot PRs run the real scanner, and every other PR gets a
status-only gate.
# .github/workflows/dependency-safety.yml
name: Dependency Safety
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
statuses: write
issues: write
concurrency:
group: dependency-safety-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
safety:
# Real scan for Dependabot only; non-Dependabot PRs are handled by
# dependency-safety-non-bot-gate.yml. Keep this field paired with the
# non-bot gate's complementary condition.
if: github.event.pull_request.user.login == 'dependabot[bot]'
uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v4
secrets: inherit# .github/workflows/dependency-safety-non-bot-gate.yml
name: Dependency Safety Non-Bot Gate
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] status-only path; never checks out or runs PR code
types: [opened, synchronize, reopened]
branches: [main] # include every branch where dependency-safety / gate is required
permissions: {}
concurrency:
group: dep-safety-gate-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
gate:
permissions:
statuses: write
uses: j7an/shared-workflows/.github/workflows/dependency-safety-non-bot-gate.yml@v4This non-bot gate is the companion to a Dependabot-gated scanner caller. If
your scanner caller runs ungated, use the fork-only pattern below instead. Do
not mix a Dependabot-gated scanner with a fork-only gate: same-repo human PRs
would have no writer for the required status. The non-bot wrapper's branches:
filter must cover every branch where a ruleset or branch protection rule
requires dependency-safety / gate.
Why the wrapper is safe: pull_request_target runs in your repo's context
with a write token, which is exactly what is needed to post the status, and it
is safe here only because the wrapper delegates to a status-only reusable
workflow. The reusable workflow performs no checkout, uses no third-party
actions, runs no dependency install/build/test, executes no PR-authored files,
requests only statuses: write, uses only the automatic github.token, and
passes PR-derived values into shell through env:. Do not add
secrets: inherit to the non-bot gate wrapper.
Alternative: ungated scanner plus fork-only gate. If your scanner caller
runs on every pull_request, same-repo non-bot PRs are handled by the scanner's
own pass-through branch. In that architecture, add only a fork companion:
# .github/workflows/fork-pr-gate.yml
name: Fork PR dependency-safety gate
on:
pull_request_target:
types: [opened, synchronize, reopened]
permissions:
statuses: write
jobs:
gate:
# cross-repo fork PRs only; same-repo PRs are handled by the scanner workflow
if: github.event.pull_request.head.repo.id != github.event.pull_request.base.repo.id
runs-on: ubuntu-latest
steps:
- name: Post neutral gate status
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
gh api "repos/${GH_REPO}/statuses/${HEAD_SHA}" \
-f state="success" \
-f context="dependency-safety / gate" \
-f description="Fork PR: dependency-safety scan not run; human review required" \
-f target_url="${RUN_URL}"External fork PRs get a read-only GITHUB_TOKEN under pull_request by
default, even when the caller declares statuses: write, unless a repo admin
enables Send write tokens to workflows from pull requests (docs).
The trusted pull_request_target wrapper closes that required-status gap
without running untrusted PR code.
If you run Zizmor, it will flag the wrapper's
pull_request_target trigger. That finding is expected for this constrained,
status-only pattern; verify the no-checkout, no-PR-code, statuses: write-only
envelope instead of suppressing the architectural review.
v3.0.0 removed the deprecated dependency-cooldown.yml and
cooldown-rescan.yml workflows. If your repo still references them via
@v2, the pin continues to work against the frozen v2 line; to move to
v3, follow these steps:
-
Add native cooldown to
.github/dependabot.yml(cooldown.default-days: 5or higher). -
Replace the caller
uses:line:- uses: j7an/shared-workflows/.github/workflows/dependency-cooldown.yml@v2 + uses: j7an/shared-workflows/.github/workflows/dependency-safety.yml@v3
-
Rename the input
cooldown_days→minimum_release_age_days. -
Drop
fail_on_cooldown— replaced byfail_on_age_violationwith different semantics (failure-on-violation, not pending-on-violation). -
Remove any caller workflow that uses
cooldown-rescan.yml. No rescan companion underdependency-safety.yml— the verifier is single-shot per PR event. -
Update branch protection / rulesets. The commit-status context changes from
dependency-cooldown / gatetodependency-safety / gate. Required- status-check rules on the old context will wait forever once you cut over. -
Clean up stale labels. Any
cooldown-pendinglabel managed by the legacy workflow lingers until manually removed;dependency-safety.ymldoes not touch it. -
Optional: add
rebase-strategy: disabledto yourdependabot.ymlecosystem block — avoids@dependabot rebasepulling in newer versions that have not yet aged through native cooldown.
v4.0.0 makes post-PR release-age verification opt-in and enables auto-merge
by default. Dependabot native cooldown.default-days remains the recommended
mechanism for delaying version-update PRs; the workflow no longer re-verifies
release age unless asked to.
-
Map
fail_on_age_violationtorelease_age_policy. The old input is removed — passing it to@v4fails at startup with "Invalid input":v3 v4 fail_on_age_violation: true(or unset)release_age_policy: blockingfail_on_age_violation: falserelease_age_policy: advisory— (new default: no post-PR age checks) omit the input (defaults to "off") -
Auto-merge now defaults to on. Set
auto_merge: falseto keep manual merges. With auto-merge on, the calling job must grantcontents: write. -
Keep (or add) native cooldown in
.github/dependabot.yml— under the defaultrelease_age_policy: "off"the workflow no longer verifies the age invariant, socooldown.default-daysis the only waiting period. -
Quote the policy value if you restate it.
release_age_policy: "off"— unquotedoffis a YAML boolean literal and may not survive parsing as a string.minimum_release_age_daysonly takes effect withadvisoryorblocking. -
Stale labels self-heal. A leftover
dependency-age-violationlabel from v3 is removed on the first error-free v4 scan of that PR.
This repo includes a Zizmor workflow that runs static security analysis on all workflow YAML files. It detects:
- Template injection in
run:blocks - Excessive or missing permissions
- Known CVEs in pinned action commits
- Dangerous triggers (
pull_request_target, etc.) - Supply chain risks
Zizmor runs automatically on pushes to main and on pull requests. Consumer repos can add the same workflow — see Adding Zizmor to your repo.
# .github/workflows/security.yml
name: Security
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
security-events: write
jobs:
zizmor:
name: Workflow Security Analysis
runs-on: ubuntu-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2
with:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
- name: Run Zizmor
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
with:
min-severity: medium
min-confidence: medium| Pin | Gets updates | Use when |
|---|---|---|
@vX |
All non-breaking changes within major X |
Default — most convenient |
@vX.Y |
Patch fixes only within minor X.Y |
Want patches but not new features |
@vX.Y.Z |
Nothing (frozen) | Need exact reproducibility or rollback |
Releases are cut manually via the release-self.yml workflow dispatch. Merging a PR to main does not create a tag on its own. When a maintainer dispatches release-self.yml (with bump: auto), it scans Conventional Commits since the last tag, computes the next semver tag, and updates the floating vX / vX.Y tags to point at the new commit.
Release tags are created as lightweight refs that point directly at the target
commit. When tag-release.yml creates a version-bump commit, that commit must
verify before main advances. When no bump commit is created, target commit
verification is reported in the summary but is not a hard gate, because caller
repositories may legitimately pass unverified commits.
Floating major and minor tags are also lightweight refs. release.yml peels the immutable release tag to its target commit, then updates or creates the floating refs through the GitHub API with forced ref updates for existing floating tags.
The reusable workflows in this repo are self-contained at runtime: they must not fetch j7an/shared-workflows source at runtime, and they must not reference caller-scoped context variables as if they were reusable-workflow-scoped.
The following are forbidden inside any workflow_call file:
| Pattern | Why it's wrong |
|---|---|
ref: ${{ github.workflow_sha }} |
Resolves to the caller's event SHA, not this workflow's commit |
ref: ${{ github.sha }} |
Same problem — resolves to caller context |
ref: ${{ github.ref }} |
Same problem — resolves to caller's branch/tag ref |
This policy exists because violating it caused #29: v2.0.2 shipped with a broken actions/checkout step that failed deterministically on every cross-repo consumer PR. The CI gate that should have caught it was structurally incapable of doing so, because ci-cooldown.yml self-consumed via local path (uses: ./...), which makes the caller repo the same as the checkout target and masks caller-context bugs by coincidence.
If a future reusable workflow needs to execute a script that's under version control in this repo, inline the script into the workflow YAML. The bats test suite under tests/ provides unit-test coverage against the standalone scripts/*.sh files, and scripts/check-inline-sync.sh verifies the inline copies stay in sync — so test feedback is preserved without introducing a runtime source-fetch dependency.
Before opening a PR that adds or modifies a workflow_call file:
- Review the constraints above — no runtime source fetching, no caller-context refs
- The lint rule enforces this in CI —
scripts/lint-workflow-call.shruns as thelint-workflow-calljob inci-scripts.ymland will fail your PR if it detects a forbidden pattern - Cross-repo smoke testing is planned (#30) — a companion repo will exercise reusable workflows from a genuinely external caller context to catch bugs that the self-consumption harness cannot detect
Run these locally before opening a PR that touches .github/workflows/ or scripts/:
./scripts/lint-workflows.sh # workflow/YAML structure (actionlint, non-hanging mode)
bats tests/ # script / runtime behavior
./scripts/check-inline-sync.sh # inline copies match scripts/*.sh
./scripts/lint-workflow-call.sh # no caller-context refs in workflow_call filesOptional, advisory shell analysis:
shellcheck scripts/*.sh # completes, but has known info-level findings; not a gateWhy lint-workflows.sh instead of plain actionlint? Default actionlint
(with its ShellCheck integration enabled) hangs on
.github/workflows/dependency-safety.yml: that file carries a large inlined
Scan and report Bash block (required by the inline-sync architecture),
which interacts badly with actionlint's ShellCheck orchestration. The hang is a
tool limitation, not a workflow syntax error, and it is pre-existing on
main. The wrapper disables that integration (actionlint -shellcheck= -pyflakes=) so structural linting completes deterministically. ShellCheck still
runs as a separate, optional signal against the source scripts.
tag-release.yml needs a non-GITHUB_TOKEN identity to push new tags, otherwise GitHub's recursion guard silently suppresses the downstream release.yml run. We use a GitHub App for this.
| Kind | Name | Value |
|---|---|---|
| Repo variable | RELEASE_BOT_APP_ID |
Numeric App ID |
| Secret | RELEASE_BOT_PRIVATE_KEY |
Full PEM contents including header/footer |
- Create a GitHub App (org- or user-owned) with repository permission
Contents: Read and write— nothing else. - Install the App on this repo (single-repo install recommended).
- Copy the App ID into
vars.RELEASE_BOT_APP_IDunder Settings → Secrets and variables → Actions → Variables. - Generate a private key from the App settings and store the PEM as
RELEASE_BOT_PRIVATE_KEY. A repo-level Actions secret works with the sample caller below; if you scope release credentials to thereleaseenvironment, expose the same secret name there instead.
Dispatch Actions → Tag Release → Run workflow with bump=patch. Within ~30 seconds, a new run of Publish Release should appear:
gh run list --workflow=release.yml --limit 1To rotate the key: generate a new private key in the App settings, update secrets.RELEASE_BOT_PRIVATE_KEY, then delete the old key in the App settings. No code change required.
tag-release.yml and release.yml are reusable workflows. Downstream repos can cut and publish releases by adding a thin caller that delegates to this repo — no copy-pasted release logic.
# .github/workflows/release.yml
name: Release
on:
workflow_dispatch:
permissions:
contents: write
jobs:
tag:
uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v4
secrets:
RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
publish:
needs: tag
uses: j7an/shared-workflows/.github/workflows/release.yml@v4
with:
tag: ${{ needs.tag.outputs.tag }}Dispatching this workflow runs tag-release with the default bump: auto (infers the semver bump from conventional commit prefixes), then publishes the resulting tag.
Use this variant if you want to expose a picker in the Actions UI so operators can force a specific bump level.
# .github/workflows/release.yml
name: Release
on:
workflow_dispatch:
inputs:
bump:
type: choice
options: [auto, patch, minor, major]
default: auto
permissions:
contents: write
jobs:
tag:
uses: j7an/shared-workflows/.github/workflows/tag-release.yml@v4
with:
bump: ${{ inputs.bump }}
secrets:
RELEASE_BOT_PRIVATE_KEY: ${{ secrets.RELEASE_BOT_PRIVATE_KEY }}
publish:
needs: tag
uses: j7an/shared-workflows/.github/workflows/release.yml@v4
with:
tag: ${{ needs.tag.outputs.tag }}If your repo has version strings in committed JSON files (e.g. server.json, package.json), tag-release.yml can rewrite them as part of the release commit. Add a .version-bump.json at repo root listing the files and locations to update. See .github/workflows/README.md › Version file bumping for the schema, examples, and security model.
- Create a
releaseGitHub Environment, restricted to themainbranch via deployment branch policy (Settings → Environments → New environment → Deployment branches → Selected branches →main). - Make
RELEASE_BOT_PRIVATE_KEYavailable assecrets.RELEASE_BOT_PRIVATE_KEYin the caller repo. The sample snippets work with a repo-level Actions secret; if you scope release credentials to thereleaseenvironment, keep the same secret name there so the caller can forward it unchanged. - Set
vars.RELEASE_BOT_APP_IDas a repo variable pointing at the Release Bot GitHub App's numeric App ID. - Install the Release Bot App on the repo with
Contents: Read and writepermission (see Release Bot App setup above for provisioning).
The environment: release + if: github.ref == 'refs/heads/main' gate inside tag-release.yml runs in your repo's security context — shared-workflows cannot unilaterally enforce it across consumers. If you skip step 1, you lose the environment-side branch policy and secret protection; the in-file if: check still blocks non-main refs, but the extra GitHub-side gate is gone.
@v4 is the floating major tag for the current v4.x.y line. It always
points at the latest v4.x.y release because release.yml force-updates
floating majors on every publish. Pinning to @v4 means you get all
non-breaking updates within v4 automatically. Pin to @v4.0 for patch-only
updates, or @v4.0.0 for an immutable freeze — see the Versioning
section above.
@v3 is the previous line, frozen at the last release where post-PR
release-age verification was on by default and auto-merge was opt-in. @v2
is the frozen historical cooldown-bearing line. Both continue to work but
receive no further updates — see v3 → v4 migration.
publish-pypi.yml remains in this repo for compatibility with the published
@v4 surface, but it is not the recommended Trusted Publishing path for new
package releases. The caller-owned template in
.github/workflows/README.md
is the canonical PyPI/TestPyPI guidance.
TestPyPI install verification uses an explicit Python version (verify-python
for the reusable workflow). The verification job writes an ephemeral
.verify/pyproject.toml and pins only the package under test to TestPyPI with
an explicit uv source. Normal dependencies continue to resolve from PyPI.