This repo (jr200-labs/github-action-templates) hosts reusable workflows
called by consumer repos across multiple GitHub orgs. Every gotcha below
was discovered the hard way during the initial setup (April 2026).
GitHub's docs either don't cover these or bury them.
secrets: inherit on a reusable workflow call does not pass org-level
secrets across org boundaries. A repo in one org calling a reusable
workflow in another org will NOT inherit the caller org's secrets.
Confusingly, vars.* (variables) DO cross orgs via the same mechanism —
only secrets are blocked.
Workaround: Declare the secret as a named input on the reusable
workflow's workflow_call.secrets block. The caller passes it explicitly:
# Consumer wrapper (in a different org than the reusable workflow)
secrets:
INTEGRATION_APP_PRIVATE_KEY: ${{ secrets.INTEGRATION_APP_PRIVATE_KEY }}Once you declare ANY named secret on workflow_call.secrets, only the
named ones flow through — you lose the magic inherit behaviour for that
workflow, even within the same org.
Example: .github/workflows/renovate.yml in this repo declares
INTEGRATION_APP_PRIVATE_KEY as a named optional secret for exactly
this reason.
Org-level secrets and variables with visibility: all do NOT resolve at
workflow runtime for private repositories on the GitHub Free plan.
The API misleadingly reports visibility: all regardless of plan.
Root cause: GitHub Free only propagates org secrets/variables to public repos. Orgs where all repos are private see org inheritance blocked; orgs on Free with public repos don't hit this.
Workaround: Add per-repo duplicates of the variable + secret on every private repo that needs them:
gh variable set INTEGRATION_APP_ID --repo <org>/<repo> --body "<app-id>"
gh secret set INTEGRATION_APP_PRIVATE_KEY --repo <org>/<repo> < key.pemLong-term fix: Upgrade to GitHub Team ($4/user/month). Then delete all per-repo duplicates — the org-level settings take effect immediately.
Tracked in: consumer-org issue tracker (per-org investigation + upgrade decision).
You cannot reference secrets.X in a step-level or job-level if:
condition. GitHub Actions evaluates if: expressions before secrets are
bound to the step context.
Workaround: Pair every secret with a non-sensitive variable. Gate on the variable:
- name: Mint App installation token
if: ${{ vars.INTEGRATION_APP_ID != '' }}
uses: actions/create-github-app-token@v3
with:
app-id: ${{ vars.INTEGRATION_APP_ID }}
private-key: ${{ secrets.INTEGRATION_APP_PRIVATE_KEY }}Even when org variables are visible via the API, vars.* inside a
reusable workflow resolves from the caller's context. On private
repos (Free plan), the caller can't see org-level variables.
Workaround: Pass the variable from the caller's with: block:
# Consumer wrapper
with:
app-id: ${{ vars.INTEGRATION_APP_ID }}The reusable workflow uses inputs.app-id instead of vars.INTEGRATION_APP_ID:
if: ${{ inputs.app-id != '' || vars.INTEGRATION_APP_ID != '' }}The fallback to vars.* keeps backward compat for callers that haven't
been updated. See the app-id input on renovate.yml in this repo.
The org-level setting "Allow GitHub Actions to create and approve pull
requests" (Settings → Actions → General → Workflow permissions) only
controls secrets.GITHUB_TOKEN. App installation tokens minted via
actions/create-github-app-token bypass this setting entirely.
Symptom if disabled: Workflows using GITHUB_TOKEN fail with:
GraphQL: GitHub Actions is not permitted to create or approve pull requests
Fix: Enable at the org level, or use App tokens (which bypass it).
Without the workflows:write permission on the GitHub App, pushes that
modify .github/workflows/*.yml are rejected — even for innocent
changes like bumping actions/checkout version.
The per-org integration Apps used for this workflow all have this permission.
If a caller forgets secrets: inherit (or the explicit secrets: block),
the reusable workflow simply doesn't see any secrets. No error, no
warning. The App token mint step silently falls back to GITHUB_TOKEN.
How to detect: After wiring a new consumer, trigger a manual
workflow_dispatch and check the logs. Look for:
- "Mint App installation token" step: ✓ (ran) vs - (skipped)
- The
app-id:line in the step'swith:block:***(present) vs empty
@v44, @v46 don't exist — there's no floating major tag. Pin to a
specific patch version like @v46.1.8. Renovate itself can be bumped
independently via the action's renovate-version input (currently
defaults to 43).
pnpm-workspace.yaml containing overrides: null (perfectly valid
YAML and accepted by pnpm) crashes Renovate's npm extractor with a
zod validation error.
Workaround: Remove the overrides: null line.
If you roll back a deployment by pinning an older image tag in
a consumer repo's IaC values.yaml, Renovate sees the newer (broken)
tag as "latest" and bumps it back on the next run.
Correct approach:
- Publish a NEW version with the fix (so the broken version is no longer "latest")
- Mark the broken version's GitHub Release as pre-release
(
gh release edit <tag> --prerelease) — Renovate skips pre-releases
Safety net: Consumer IaC repos can wire a .githooks/pre-commit
that automatically marks skipped versions as pre-release when it detects
a tag downgrade in values.yaml.
After Renovate bumped actions/create-github-app-token from @v2 to
@v3, some per-repo INTEGRATION_APP_PRIVATE_KEY secrets stopped
working with: A JSON web token could not be decoded.
Fix: Re-set the secret with a fresh .pem file:
gh secret set INTEGRATION_APP_PRIVATE_KEY --repo <repo> < key.pemFor v3 workflows, prefer client-id. Older app-id usage can still
appear in historical workflow runs and should be moved opportunistically.
When a caller workflow sets top-level permissions: that doesn't include
every scope the reusable workflow's jobs need, the whole run can fail with
startup_failure — before any job logs are emitted. The UI shows
0 jobs, duration ~1s, and the API returns no annotations.
Observed when first adopting release_please.yaml on a private repo:
the caller had permissions: contents: read at top level; the
reusable's release job declares contents: write, pull-requests: write.
Startup failed immediately.
Confusingly, the identical pattern works on some repos — suggesting the
cascade depends on org-level/repo-level workflow permission defaults
(default_workflow_permissions, can_approve_pull_request_reviews)
that differ silently between repos.
Workaround: Always declare the full set of scopes the reusable needs
at the caller's top-level permissions: block. For release-please:
permissions:
contents: write
pull-requests: writeAlso add workflow_dispatch: to the trigger list so you can retrigger
manually for diagnosis without needing a push to master.
How to detect: Watch the first post-merge workflow run. If it shows
startup_failure with 1s duration and no job logs, suspect permissions
before touching YAML syntax.
Refactor gotcha: When converting an inline job to a reusable
workflow call, promote job-level permissions: to the caller's
top-level block. Job-level grants supersede top-level for inline jobs
but do NOT cascade into a reusable workflow — only the caller's
top-level permissions: propagates. A refactor that swaps
jobs.foo.runs-on + jobs.foo.permissions for jobs.foo.uses: while
leaving top-level permissions: contents: read will silently regress
to startup_failure on every run. Observed in the wild: an inline
release-please job with contents: write, pull-requests: write at
job level worked fine; refactoring to
uses: .../release_please.yaml@master without moving the permissions
block up broke three releases before anyone noticed.
The per-org integration Apps all have:
| Permission | Why |
|---|---|
actions: read |
Read workflow runs |
contents: write |
Push commits, create branches |
issues: write |
Renovate Dependency Dashboard |
metadata: read |
Required for all Apps |
packages: read |
Access ghcr.io private packages |
pull_requests: write |
Create/update Renovate PRs |
statuses: read |
Read commit statuses (integration gate) |
workflows: write |
Push changes to .github/workflows/ |
Key rotation is tracked in JRL-18 (due 2027-04-11).