This repository provides two TypeScript-based JavaScript actions implementing a fork-safe two-stage GitHub Actions pattern:
- Unprivileged workflow (
pull_request,issue_comment,pull_request_review, etc.) emits structured data as an artifact. - Privileged workflow (
workflow_run) consumes and validates that artifact before doing writes/secrets operations.
| Action | Purpose |
|---|---|
privilege-escalation-bridge/emit |
Package outputs, metadata, optional selected event payload, and optional files into a stable artifact schema |
privilege-escalation-bridge/consume |
Download and validate producer artifact from workflow_run, then expose outputs and extracted fields |
The bridge does not bypass GitHub's permissions model.
Defenses included:
- Source run binding:
meta.workflow_run_idmust match expected run. - Run-attempt binding:
meta.workflow_run_attemptmust match expected attempt when available. - Repository binding:
meta.repositorymust match current repository. - Optional pinning: workflow name, event type, PR number, and head SHA checks.
- Fail-closed defaults for missing artifact and validation mismatches.
Non-goals:
- Trusting artifact contents as safe.
- Passing secrets through artifacts.
- Preventing logical misuse of untrusted outputs by downstream steps.
Artifact payload layout:
bridge/
outputs.json
meta.json
files/
...optional copied files
- JSON object.
- Keys must match
^[A-Za-z_][A-Za-z0-9_]*$instrictmode. - Values must be scalars (
string,number,boolean,null) instrictmode.
Required fields (always provided by emit):
schema_version- Internal contract version. Current value is2; consumers fail if it differs.repository-owner/repoof the producer run.workflow_name- Producer workflow name (GITHUB_WORKFLOW).workflow_run_id- Producer run id as a string.workflow_run_attempt- Producer run attempt as a string.event_name- Producer event name (for examplepull_request,issue_comment).head_sha- Producer commit SHA.created_at- ISO timestamp generated byemit.
Optional fields (provided by emit when available):
pr_number- Numeric PR/issue number when one exists in the event payload.producer_job- Producer job id (GITHUB_JOB) when available.producer_step- Reserved for compatibility; may be absent.event- Event payload object, controlled byinclude_event/event_fields.- Any additional keys from
emit.meta.
Notes:
metais validated strictly on consume for required fields/types and schema version.emit.metacan override default keys if you reuse the same names; avoid overriding core keys unless intentional.eventonly contains scalar leaves (strings/numbers/booleans/null) when selected viaminimal/event_fields.
include_event: none- nometa.eventkey is added.include_event: minimal- a fixed curated subset is included (listed below).include_event: full- entiregithub.context.payloadis included.event_fieldspresent - overridesinclude_eventand includes only the listed paths.
Exact include_event: minimal paths:
actionsender.loginsender.typeissue.numberissue.titleissue.html_urlissue.user.logincomment.bodycomment.pathcomment.user.loginreview.bodyreview.statereview.user.loginpull_request.numberpull_request.titlepull_request.html_urlpull_request.user.loginpull_request.base.refpull_request.base.shapull_request.base.repo.full_namepull_request.head.refpull_request.head.shapull_request.head.repo.full_name
Path rules:
- Paths use dot notation (
pull_request.user.login). - Multiple paths are comma or newline separated.
- Missing paths are ignored.
- Nested objects are reconstructed from the selected scalar leaves.
Path: emit/action.yml
artifact(default:bridge)- Artifact name to upload.
outputs- JSON object string of outputs.
- Merge precedence: wins over keys from
outputs_filewhen both specify the same key.
outputs_file- Path to a JSON object file of outputs.
files- Newline-separated paths copied into
bridge/files/. - Must be relative paths and may not escape the workspace (
..is rejected).
- Newline-separated paths copied into
retention_days- Artifact retention passed to GitHub artifact upload.
sanitize(strictdefault, ornone)strict: enforce output key regex and scalar-only output values.none: skip those checks (consumer still reads with strict output validation).
meta- Extra metadata JSON object merged into
meta.json.
- Extra metadata JSON object merged into
include_event(none,minimal,full; defaultminimal)- Controls whether/how
meta.eventis populated whenevent_fieldsis not set.
- Controls whether/how
event_fields- Optional comma/newline-separated allowlist of event paths.
- If non-empty, it overrides
include_event.
artifactoutputs-jsonmeta-json
Path: consume/action.yml
token(recommended)- Token with
actions:readfor downloading artifacts. - No default is set by this action.
- In reusable workflows, pass explicitly:
token: ${{ github.token }}.
- Token with
github_token(deprecated alias fortoken)- Supported for backward compatibility.
- Env fallback
- If neither input is set,
consumechecksGITHUB_TOKENthenGH_TOKEN. - Caller
envvalues are not automatically inherited by reusable workflows. - Must allow
actions:readin the target repository.
- If neither input is set,
artifact(default:bridge)- Artifact name to download from the producer run.
override_json- Optional JSON object with canonical bridge payload fields
metaandoutputs. - When non-empty,
consumeskips token resolution and artifact download and uses this payload instead. metamust satisfy the normal bridge metadata schema;outputsmust be a JSON object with scalar values.bridge/filesrestore is not supported in this mode, sofiles-pathis not emitted.
- Optional JSON object with canonical bridge payload fields
run_id(defaults to triggeringworkflow_run.id)- Required unless the action is running under a
workflow_runevent. - In
override_jsonmode, this binding is only checked when a run id is available from input or event context.
- Required unless the action is running under a
source_workflow- Optional exact match against
meta.workflow_name.
- Optional exact match against
expected_head_sha- Optional exact match against
meta.head_sha. - Usually not needed for
workflow_runconsumers becauserun_id(andrun_attemptwhen present) are already validated by default. - For
pull_requestproducers, this is often the synthetic merge commit SHA (refs/pull/<n>/merge), which changes when the base branch moves and can make this check brittle.
- Optional exact match against
expected_pr_number- Optional exact match against
meta.pr_number.
- Optional exact match against
require_event(comma/newline-separated event names)- Allowlist for
meta.event_name.
- Allowlist for
fail_on_missing(default:true)true: missing artifact fails the action.false: missing artifact does not fail; JSON outputs are emitted as{}and no per-key outputs are emitted.
expose(outputs,env,both; defaultoutputs)- Controls where per-key bridge outputs and extracted mappings are written.
outputs: step outputs only.env: exported environment variables only.both: both outputs and env vars.
prefixprefix for per-key bridge outputs- Applied only to direct bridge output keys from
outputs.json. - Not applied to names from
extractmappings.
- Applied only to direct bridge output keys from
extractnewline-separated mappings:NAME=source.path- Supported roots:
outputs,meta,event. - Every configured mapping must resolve to a scalar value or
null; missing paths fail the action. - Fallback paths are supported:
a.b|c.d(first found wins).
- Supported roots:
pathrestore destination forbridge/files(default.bridge)- Files are copied into this directory recursively.
- Per-key outputs (subject to
exposeandprefix) - Extracted outputs from
extractmappings outputs-jsonmeta-jsonevent-jsonfiles-pathwhen artifact-backed restore ran
When fail_on_missing=false and the artifact is not found, outputs-json is {}. You can skip downstream work with a guard like if: ${{ steps.bridge.outputs.outputs-json != '{}' }}.
consume always validates:
meta.repositorymatches current repository.meta.workflow_run_idmatches requestedrun_id.meta.workflow_run_attemptmatches triggeringworkflow_run.run_attemptwhen available.- These checks are typically sufficient to bind the artifact to the triggering workflow run.
consume optionally validates when inputs are provided:
source_workflow->meta.workflow_nameexpected_head_sha->meta.head_shaexpected_pr_number->meta.pr_numberrequire_event->meta.event_namemembership
Both actions use collapsible log groups in the Actions UI for major phases.
- Standard runs show concise
core.infosummaries. - Detailed internals are emitted via
core.debugand appear when step debug logging is enabled (ACTIONS_STEP_DEBUG=true).
on: pull_request
jobs:
checks:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- run: echo '{"lint_ok":true}' > bridge.json
- run: echo '{"ok":true}' > lint.json
- uses: leanprover-community/privilege-escalation-bridge/emit@v1
with:
artifact: pr-bridge
outputs_file: bridge.json
include_event: minimal
files: |
lint.jsonon:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
jobs:
consume:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: write
pull-requests: write
steps:
- id: bridge
uses: leanprover-community/privilege-escalation-bridge/consume@v1
with:
token: ${{ github.token }}
artifact: pr-bridge
source_workflow: PR Checks
expected_head_sha: ${{ github.event.workflow_run.head_sha }}
require_event: pull_request
extract: |
pr_number=meta.pr_number
author=event.pull_request.user.login
- run: echo "pr=${{ steps.bridge.outputs.pr_number }} author=${{ steps.bridge.outputs.author }}"npm cinpm testnpm run buildApache-2.0