Skip to content

Add: github-app-token, a fail-closed replacement for cross-repo PATs - #30

Merged
gambe94 merged 5 commits into
mainfrom
feat/github-app-token
Aug 14, 2026
Merged

gambe94 merged 5 commits into
mainfrom
feat/github-app-token

Conversation

@gambe94

@gambe94 gambe94 commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Adds github-app-token, the sanctioned way for any repo in the fleet to reach another one.

Why

secrets.GITHUB_TOKEN cannot touch another repository, so every cross-repo job reached for a personal access token. Two are in service today — E2E_DISPATCH_TOKEN in sf-docker-images and CATALOG_SCAN_TOKEN here. A PAT belongs to a person, carries account-wide scope, never expires on its own, and dies with the account.

Why it isn't a thin wrapper

actions/create-github-app-token is generous in two directions at once:

  • omit repositories → the token reaches every repo in the installation
  • omit every permission-* → it inherits every permission the App holds

Do both and you have replaced an over-broad PAT with an over-broad token that merely expires sooner. So this action fails closed: it refuses to mint until the caller has named the repositories and named the permissions. allow-broad-scope: true is the deliberate opt-out and logs a warning saying so.

It also catches three things that would otherwise be diagnosed from a bare 401/404 several steps later: an empty client-id/app-id (an org variable not shared with the calling repo arrives as an empty string, indistinguishable from never passing one), an empty private key, and a key pasted without its PEM armour.

Testing

Three smoke steps in ci.yml pin the guards. They run with a throwaway key and no App credentials at all — which is the point: the two scope rejections would otherwise succeed and hand back a working, over-broad token.

Verified locally across 11 input combinations before wiring (every reject case rejects, every scoped case passes, allow-broad-scope still opens the door).

Note on organization-packages

Exposed separately from packages, because the split is not obvious and gets people: pulling/pushing an image uses packages, but package visibility (PATCH /orgs/{org}/packages/...) and version deletion are org endpoints that 403 with packages alone.

Follow-ups

  • This must be released before sf-docker-images can consume it — that PR references @v2, so the v2 tag needs to move.
  • CATALOG_SCAN_TOKEN in catalog-refresh.yml can move to the same App; worth its own change.

Setup doc: docs/github-app-setup.md.

🤖 Generated with Claude Code

gambe94 and others added 4 commits August 14, 2026 16:49
CLAUDE.md duplicated docs/pipeline-map.md and docs/architecture.md inline.
Replaced with summaries plus links; the docs were already the better source.
Verified every fact in the removed text against the two routed docs before
cutting it - nothing was dropped, it was already covered there.

Adds the gforce-github-actions skill (lockfile) and the gha-workflow-author
agent, and AGENTS.md as a symlink for cross-vendor coverage.

Also fixes two pre-existing defects in docs/ surfaced by reviewing the new
skill, since it now routes every session straight to these files:

- docs/consuming-sf-cicd.md still pointed at the frozen v1 release tag and
  told readers to copy examples/sf-pr-validate.yml and
  examples/sf-release.yml, filenames that do not exist post-rename (they are
  reusable-sf-pr-validate.yml and reusable-sf-release.yml now). Its sibling
  consuming-sf-dispatch.md was updated to v2 during the rename; this one was
  missed. Fixed both.
- docs/claude-actions-reference.md was missing entries for six assets added
  in PR #27 and PR #28: sf-artifact-build, sf-artifact-deploy,
  sf-env-config-apply, reusable-sf-org-deploy, smoke-sf-artifact, and
  smoke-sf-org-login. Added an entry for each, sourced from each asset's own
  action.yml / workflow file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
069f926's message claimed "nothing was dropped" when CLAUDE.md's TypeScript-
actions text was cut. That claim was inaccurate: the removed text named
ApexTestSelectionService and package.xml parsing as the substance of
libraries/salesforce, and neither appeared anywhere under docs/ -
architecture.md described that directory only generically. Restored the
detail to docs/architecture.md (the doc that owns code layering), not back
into CLAUDE.md, which is already near its 150-line budget.

Also:

- docs/consuming-sf-cicd.md: the @v1 fix in 069f926 was incomplete. Lines 9,
  18, 52 and 55 still used the pre-rename short names sf-pr-validate.yml /
  sf-release.yml while the real files, examples/README.md, and this doc's
  own link targets all use reusable-sf-pr-validate.yml /
  reusable-sf-release.yml - line 52 was self-contradictory within one link.
  Completed the rename throughout; re-read the file end to end afterward and
  found no remaining v1 references, no dead paths, and no contradiction with
  consuming-sf-dispatch.md.
- docs/claude-actions-reference.md: sf-artifact-build's head-commit entry
  claimed a default of GITHUB_SHA; the action.yml declares default: "" with
  the $GITHUB_SHA fallback applied in the script body at runtime. Corrected.
- docs/claude-actions-reference.md: the smoke-sf-artifact.yml entry called
  reject-validate-without-tests "a fifth job" while conflating it with the
  workflow's five-property header comment. The workflow has 4 jobs
  (build-artifact covers properties 1/3/4, unset-replacement-fails covers 2,
  reject-tampered-artifact covers 5); reject-validate-without-tests is the
  4th job, sitting outside the five-property enumeration. Reworded.
- docs/claude-actions-reference.md: the note flagging that sf-org-login
  predates PR #16's credential-source: github-env input lived only at the
  end of the smoke-sf-org-login.yml entry (~line 396), 280 lines from the
  stale sf-org-login entry itself (~line 115) where an editor would actually
  need it. Added a one-line pointer at the sf-org-login entry; left the
  existing note in place. The underlying staleness is still out of scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
graphify was uninstalled on 2026-08-14; CLAUDE.md still told every session it
was available, and omitted rtk, which is the thing actually compressing Bash
output through a PreToolUse hook.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`secrets.GITHUB_TOKEN` cannot reach another repository, so every cross-repo job
in the fleet reached for a personal access token instead. Two are in service
today — `E2E_DISPATCH_TOKEN` in sf-docker-images and `CATALOG_SCAN_TOKEN` here.
A PAT is the worst credential available for the job: it belongs to a person, it
has account-wide scope, it never expires on its own, and it dies with the
account.

An App installation token is better on all four counts, but only if it is
actually scoped. `actions/create-github-app-token` is generous in two directions
at once — omit `repositories` and the token reaches every repo in the
installation; omit every `permission-*` and it inherits every permission the App
holds. Do both and the replacement for an over-broad PAT is an over-broad token
that merely expires sooner.

So this action refuses to mint until the caller has named the repositories AND
named the permissions. `allow-broad-scope: true` is the deliberate opt-out, and
it logs a warning saying so.

Also validated here, because each failure is otherwise diagnosed from a bare
401/404 in a later step:

  - neither client-id nor app-id set (an unshared org variable arrives as an
    empty string, indistinguishable from never passing one)
  - a private key without its PEM armour
  - an empty private key

`client-id` is preferred; upstream deprecated the numeric `app-id` in v3, and
both are accepted so a caller can migrate without a flag day.

Three CI smoke steps pin the guards. They run with a throwaway key and no App
credentials at all, which is the point — the two scope rejections would
otherwise SUCCEED and hand back a working, over-broad token. Verified locally
across 11 input combinations before wiring: every reject case rejects, every
scoped case passes, and `allow-broad-scope` still opens the door.

Registered with Dependabot per the authoring convention — the action carries a
third-party `uses:`, and composite directories are not covered by "/".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	docs/claude-actions-reference.md
@gambe94
gambe94 merged commit eff9d97 into main Aug 14, 2026
3 checks passed
@gambe94
gambe94 deleted the feat/github-app-token branch August 14, 2026 17:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant