Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/fork_ci_label_guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Strips the CI-enabling label from a fork PR on events that may invalidate
# the prior CODEOWNER review (new commits, close/reopen lifecycle). Paired
# with a `pull_request_target` trigger in the caller that gates acceptance
# on the label's presence: removing the label here forces a fresh CODEOWNER
# review and re-label before any subsequent run with secrets.
name: "Fork CI Label Guard"

on:
workflow_call:
inputs:
label:
description: "Label to strip from the PR on each new commit."
required: false
default: "allowed-for-ci"
type: "string"

permissions:
pull-requests: write

jobs:
strip-label:
name: "Strip CI label"
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target'
steps:
- name: "Remove label"
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
LABEL: ${{ inputs.label }}
run: |
gh pr edit "$PR" --repo "$REPO" --remove-label "$LABEL" || true
66 changes: 59 additions & 7 deletions .github/workflows/module_acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ on:
type: boolean


permissions:
contents: read

env:
SERVICE_URL: ${{ inputs.service_url }}

Expand All @@ -36,6 +39,13 @@ jobs:
setup_matrix:
name: "Setup Test Matrix"
runs-on: ubuntu-latest
# Defense-in-depth: on `pull_request_target` (where this workflow runs
# with secrets in scope), fork PRs require the `allowed-for-ci` label.
# Other invocations are unaffected.
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.fork != true ||
contains(github.event.pull_request.labels.*.name, 'allowed-for-ci')
outputs:
acceptance_matrix: ${{ steps.get-matrix.outputs.matrix }}

Expand All @@ -46,6 +56,9 @@ jobs:

- name: "Checkout"
uses: "actions/checkout@v4"
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: "Configure forge authentication"
env:
Expand Down Expand Up @@ -73,9 +86,38 @@ jobs:
run: |
bundle exec matrix_from_metadata_v3 ${{ inputs.flags }}

approval_gate:
name: "Fork PR approval gate"
needs: setup_matrix
runs-on: ubuntu-latest
# Funnels the `puppetcore-fork` environment approval through a single
# non-matrix job. Without this, every matrix entry of `acceptance` would
# declare `environment:` and create its own deployment event, producing
# 2N timeline notifications per labelled fork PR. Same-repo PRs and
# `workflow_dispatch` skip this job entirely; the `acceptance` job
# below handles the skipped case.
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.head.repo.fork == true
environment: puppetcore-fork
steps:
- name: "Approval received"
run: |
echo "Approved fork PR #${{ github.event.pull_request.number }}"
echo "Head SHA at approval: ${{ github.event.pull_request.head.sha }}"

acceptance:
name: "Acceptance tests (${{matrix.platforms.label}}, ${{matrix.collection.collection || matrix.collection}})"
needs: "setup_matrix"
needs: [setup_matrix, approval_gate]
# Runs after both setup_matrix and approval_gate. The `always()` is
# required so the if-expression is evaluated when approval_gate is
# skipped (non-fork events); without it, dependency-skip would
# cascade and skip acceptance too.
if: >-
always() &&
needs.setup_matrix.result == 'success' &&
(needs.approval_gate.result == 'success' ||
needs.approval_gate.result == 'skipped')
runs-on: ${{ inputs.runs_on || matrix.platforms.runner }}
timeout-minutes: 180
strategy:
Expand Down Expand Up @@ -125,6 +167,9 @@ jobs:

- name: "Checkout"
uses: "actions/checkout@v4"
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: "Disable Apparmor"
if: ${{ inputs.disable_apparmor }}
Expand All @@ -149,13 +194,16 @@ jobs:
echo ::endgroup::

- name: "Provision environment"
env:
PROVIDER: ${{ matrix.platforms.provider }}
IMAGE: ${{ matrix.platforms.image }}
run: |
if [[ "${{ inputs.kernel_modules }}" == "true" ]] && [[ "${{matrix.platforms.provider}}" =~ docker* ]] ; then
if [[ "${{ inputs.kernel_modules }}" == "true" ]] && [[ "$PROVIDER" =~ docker* ]] ; then
DOCKER_RUN_OPTS="docker_run_opts: {'--volume': '/lib/modules/$(uname -r):/lib/modules/$(uname -r)'}"
else
DOCKER_RUN_OPTS=''
fi
bundle exec rake "litmus:provision[${{matrix.platforms.provider}},${{ matrix.platforms.image }},$DOCKER_RUN_OPTS]"
bundle exec rake "litmus:provision[$PROVIDER,$IMAGE,$DOCKER_RUN_OPTS]"
FILE='spec/fixtures/litmus_inventory.yaml'
sed -e 's/password: .*/password: "[redacted]"/' < $FILE || true
if [[ -n "$TWINGATE_PUBLIC_REPO_KEY" ]]; then
Expand All @@ -164,12 +212,16 @@ jobs:


- name: "Install Puppet agent"
env:
COLLECTION_KEY: ${{ matrix.collection.collection }}
COLLECTION_STRING: ${{ matrix.collection }}
VERSION: ${{ matrix.collection.version }}
run: |
if [[ "${{ matrix.collection.version }}" ]] ; then
export PUPPET_VERSION=${{ matrix.collection.version }}
bundle exec rake 'litmus:install_agent[${{ matrix.collection.collection }}]'
if [[ "$VERSION" ]] ; then
export PUPPET_VERSION="$VERSION"
bundle exec rake "litmus:install_agent[$COLLECTION_KEY]"
else
bundle exec rake 'litmus:install_agent[${{ matrix.collection }}]'
bundle exec rake "litmus:install_agent[$COLLECTION_STRING]"
fi

- name: "Install module"
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/module_ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,21 @@ on:
default: ''
type: "string"

permissions:
contents: read

jobs:
setup_matrix:
name: "Setup Test Matrix"
runs-on: ${{ inputs.runs_on }}
# Defense-in-depth: on `pull_request_target` (where this workflow runs
# with secrets in scope), fork PRs require the `allowed-for-ci` label.
# Non-`pull_request_target` invocations (plain `pull_request`,
# `workflow_dispatch`, etc.) are unaffected and continue to run.
if: >-
github.event_name != 'pull_request_target' ||
github.event.pull_request.head.repo.fork != true ||
contains(github.event.pull_request.labels.*.name, 'allowed-for-ci')
outputs:
spec_matrix: ${{ steps.get-matrix.outputs.spec_matrix }}

Expand All @@ -46,6 +57,7 @@ jobs:
uses: "actions/checkout@v4"
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: "Install additional packages"
if: ${{ inputs.additional_packages != '' }}
Expand Down Expand Up @@ -95,6 +107,7 @@ jobs:
uses: "actions/checkout@v4"
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: "Install additional packages"
if: ${{ inputs.additional_packages != '' }}
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ The following are the workflows we currently maintain in this repository:
* mend_ruby: automates the usage of mend for vulnerability scanning on modules
* module_acceptance: runs automated acceptance CI for modules on PRs
* module_ci: runs automated unit testing CI for modules on PRs
* fork_ci_label_guard: strips the `allowed-for-ci` label from fork PRs on each new commit so privileged acceptance tests re-require CODEOWNER review
* module_release_prep: prepares the module for release by running necessary pre-release checks and tasks
* module_release: handles the release process of the module, including versioning and publishing
* tooling_mend_ruby: automates the usage of mend for vulnerability scanning on tools
* workflow-restarter-test: tests the workflow restarter functionality
* workflow-restarter: restarts workflows that have failed or need to be re-run

Note: For more information about workflows like workflow-restarter, check out our [docs](./docs/)
Note: For more information about workflows like workflow-restarter, check out our [docs](./docs/). To enable acceptance testing against private puppetcore for fork PRs, see [docs/how-to/fork-pr-ci.md](./docs/how-to/fork-pr-ci.md).

```mermaid
flowchart TD
Expand Down
110 changes: 110 additions & 0 deletions docs/how-to/fork-pr-ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# How to enable safe fork-PR CI against private puppetcore

## Description

This guide explains how to enable acceptance testing against the private Puppet Forge (puppetcore) for pull requests from forks, while keeping `PUPPET_FORGE_TOKEN` and other secrets safe from exfiltration by untrusted contributors.

The setup splits CI into two independent tracks:

- **Spec track** — runs on the standard `pull_request` event. No secrets in scope. Runs on every PR, including from forks, with no gating.
- **Acceptance track** — runs on `pull_request_target` with secrets in scope. For same-repo PRs, runs automatically. For fork PRs, runs only when a CODEOWNER applies the `allowed-for-ci` label after reviewing the PR's contents.

## Threat model

Acceptance tests run `bundle exec rake litmus:*`, which executes arbitrary Ruby and shell scripts from the PR's working tree (Rakefile, spec helpers, fixtures). Under `pull_request_target` these scripts run in the base-repo's privileged context with secrets available — code execution from PR-controlled code is the *design*, so we cannot eliminate it. Every control below exists to ensure that execution only happens with explicit, fresh, human consent.

A malicious fork PR could (a) modify `Rakefile`, `spec/**`, `metadata.json`, `Gemfile`, `.fixtures.yml` to exfiltrate environment variables, (b) attempt shell injection through interpolated workflow inputs, (c) push to the fork after a CODEOWNER labels the PR, hoping the label sticks. The controls below close (a) via human review before label, (b) via env-var passthrough in the reusable workflows, and (c) via automatic label removal on each new commit.

## Prerequisites

- A puppetlabs module repository.
- Repo admin access to configure labels, rulesets, environments, and secrets.
- Existing `PUPPET_FORGE_TOKEN` (and, where applicable, `TWINGATE_PUBLIC_REPO_KEY`) secrets.

## Configuration steps

Complete **all** of these before flipping the caller workflow to the new pattern. Each step is independent; verify each before moving on.

### 1. Create the `allowed-for-ci` label

In the module repo: **Issues → Labels → New label**. Name it exactly `allowed-for-ci`. Description: "CODEOWNER review complete; run acceptance against private puppetcore for this commit."

### 2. Audit who can apply the label (repository roles)

GitHub rulesets do not currently target labels; label add/remove is gated by **repository role**. Anyone with **Triage** permission or higher can apply any label; external fork contributors have **Read** by default and cannot apply labels at all.

Audit `<MODULE>` → **Settings → Collaborators and teams**. Confirm nobody outside `@puppetlabs/modules` / `@puppetlabs/devx` (or other explicitly trusted internal teams) has Triage or higher. If a community triage team or external contractor has Triage+, decide whether they should be able to apply `allowed-for-ci`; if not, drop their role to Read or accept them as an additional trust principal and record that in the rollout ticket.

For stricter per-label enforcement (only `@puppetlabs/modules` members, not all Triage+ users), see [Defense-in-depth follow-up](#defense-in-depth-follow-up) below.

### 3. Create the `puppetcore-fork` environment

**Settings → Environments → New environment** named `puppetcore-fork`. Configure:

- **Required reviewers**: `@puppetlabs/modules` (and/or `@puppetlabs/devx`).
- **Environment secrets**: only required if the secret currently lives at the **repo** level. For each secret used by acceptance (`PUPPET_FORGE_TOKEN`, `TWINGATE_PUBLIC_REPO_KEY`):
- If repo-level → copy the value into the environment with the same name. Keep the repo-level copy in place during pilot so same-repo PRs (which don't go through the environment) still work.
- If org-level → leave it alone. GitHub's secret resolution order is **environment → repo → org**, so an unset env secret falls through to the org level. The approval gate still fires before any step runs; the org secret is just sourced from its existing location. Verify the org secret's "Repository access" policy includes the pilot repo (org **Settings → Secrets → Actions → secret**); if the existing acceptance workflows authenticate successfully today, this is already configured.

The acceptance job uses the environment only on fork PRs (see `module_acceptance.yml` for the conditional), so non-fork PRs do not pause for approval.

### 4. Protect the `main` branch

**Settings → Rules → Rulesets**: require PRs to `main`, ≥1 CODEOWNER review, block force-push. Add a path filter requiring `@puppetlabs/devx` review for any change touching `.github/workflows/**`, `.github/actions/**`, or `CODEOWNERS`. `pull_request_target` always loads the workflow YAML from `main`, so protecting `main` is what makes the gate trustworthy.

### 5. Update the caller workflows

Replace `.github/workflows/ci.yml` with the two-track pattern (see [example/module/ci.yml](../../example/module/ci.yml)). Add a new file `.github/workflows/fork_ci_label_guard.yml` that strips the label on each new commit (see [example/module/fork_ci_label_guard.yml](../../example/module/fork_ci_label_guard.yml)).

Keep "Require approval for first-time contributors" enabled at the org Actions setting — it's an additional pre-execution gate independent of this design.

## Reviewer checklist (before applying the label)

Before a CODEOWNER applies `allowed-for-ci` to a fork PR, they **must** read the diff for these files. A change to any of them is grounds to refuse the label until the change is understood and acceptable:

- `.github/**` — any workflow change (cannot affect this run, but can land if merged).
- `Rakefile` — runs during acceptance.
- `spec/**` — spec helpers and fixtures execute during tests.
- `Gemfile`, `Gemfile.lock`, `metadata.json` — control which code is loaded.
- `.fixtures.yml` — controls test fixture sources.
- Any file under `tasks/`, `plans/`, `functions/`, or anywhere else that is loaded or executed as part of `litmus:provision`, `litmus:install_module`, or `litmus:acceptance:parallel`.

The Spec track has already run by the time the reviewer sees the PR; review its result alongside the diff.

## How it works

- A fork contributor opens a PR. `Spec` runs immediately (public track, no secrets). `Acceptance` does not run.
- A CODEOWNER reviews the diff, focusing on the files above, and applies `allowed-for-ci`. The `labeled` event triggers the workflow on `pull_request_target`. A single `approval_gate` job (non-matrix) targets the `puppetcore-fork` environment and pauses for environment approval — the approval prompt shows the head SHA being deployed, which the reviewer should compare against the SHA they reviewed before approving. A `@puppetlabs/modules` member approves; `approval_gate` completes; the matrix `acceptance` jobs then run with `PUPPET_FORGE_TOKEN` in scope.
- If the contributor pushes a new commit, `fork_ci_label_guard.yml` fires on `synchronize` and removes the label. The main caller workflow does **not** include `synchronize` in its `pull_request_target` trigger, so no acceptance run is created for the new commit. To run acceptance against the new code, a CODEOWNER must review again and re-apply the label.
- If the PR is closed, `fork_ci_label_guard.yml` also fires on `closed` and removes the label. This prevents an attack where a contributor closes the PR, pushes new commits (no events fire on a closed PR), then reopens — without close-strip the label would persist across the reopen and the new commits would inherit the prior authorisation. Side effect: closing a merged PR also strips the label, which is cosmetic (the label is only meaningful for open PRs).

## Verification

End-to-end test on the pilot module before declaring done:

1. Open a fork PR. Confirm `Spec` runs and `Acceptance` does not.
2. Apply `allowed-for-ci` as a CODEOWNER. Confirm a single `approval_gate` job is queued and waits for environment approval (not one per matrix entry).
3. Approve the environment run, after verifying the SHA shown in the approval prompt matches the SHA you reviewed. Confirm `approval_gate` completes, then the matrix `acceptance` jobs run against private puppetcore.
4. Push a new commit to the fork branch. Confirm: the label is removed automatically; `Spec` re-runs on the new commit; `Acceptance` does **not** auto-run.
5. As a non-CODEOWNER test account (with **Read** role on the repo, or no role at all), attempt to add `allowed-for-ci`. Confirm GitHub blocks the action — the Labels control should not be available in the PR sidebar. (If you've added the labeller-verification follow-up workflow, also test with a **Triage** account outside `@puppetlabs/modules` and confirm the label is stripped automatically.)
6. Open a fork PR that modifies `.github/workflows/ci.yml` (e.g., removes the gate). Confirm that on `pull_request_target` the **base** workflow definition runs, so the gate still holds.

## Troubleshooting

- **Acceptance never runs on a labeled fork PR**: check that `pull_request_target` is in the trigger list and that `types:` includes `labeled`. Confirm the label name is exactly `allowed-for-ci`.
- **Acceptance pauses indefinitely**: an environment reviewer has not approved. Check **Actions → workflow run → Review deployments**.
- **Label cannot be applied**: the user's repo role is below **Triage**. Adjust their team membership or role in **Settings → Collaborators and teams**.
- **Secrets missing inside acceptance**: trace the precedence chain. For same-repo PRs (no environment), the secret must exist at repo or org level. For fork PRs (`puppetcore-fork` environment), the secret must exist at env, repo, or org level. If org-only, confirm the org secret's "Repository access" policy includes the pilot repo.

## Defense-in-depth follow-up

The repo-role audit (step 2) gates labelling at the **Triage+** level, which may include trusted-but-not-CODEOWNER principals (e.g., a community triage team). If you need a hard "only `@puppetlabs/modules` members can apply this specific label" enforcement, the recommended next layer is a small `labeller_verification.yml` reusable workflow that fires on `pull_request_target: types: [labeled]`, checks the labeller's team membership via the GitHub API, and strips the label + fails the run if the labeller is not authorised. Because that workflow never checks out PR code, the token it uses is not reachable from PR-controlled scripts — different threat model than the acceptance workflow, so the earlier "no in-workflow team check" rule does not apply here.

This is tracked as a follow-up to the initial pilot. The approval gate on the `puppetcore-fork` environment provides equivalent protection for pilot purposes.

## Security considerations

- Do **not** add `synchronize` to the `pull_request_target` types in the caller's main `ci.yml`. Doing so re-races the label-strip workflow and can allow a malicious commit to inherit a prior label's authorization.
- Do **not** pass `secrets: inherit` to the `Spec` track. It runs on `pull_request` (untrusted) and must have no secrets in scope.
- Do **not** widen the trigger from `pull_request_target` to include user-controlled refs other than the PR head SHA pinned by the reusable workflow.
- The label name `allowed-for-ci` is a contract; changing it requires updating the reusable workflow's `if:` expressions and the label-guard's input.
Loading
Loading