Skip to content
Closed
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
12 changes: 11 additions & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ version: 2.1
orbs:
win: circleci/windows@5.0

parameters:
# Set only by .github/workflows/fork-ci-mirror.yml through the CircleCI
# trigger API, after a maintainer approved a fork PR head with the ci:run
# label. GitHub App pipelines never fire for fork PRs on their own.
fork_pr:
type: string
default: ""

jobs:
pr-check:
executor:
Expand All @@ -22,6 +30,7 @@ jobs:
# from the environment with [AllowEmptyString] defaults.
CBX_PR_URL: << pipeline.event.context.github.pr_url >>
CBX_PR_BASE_SHA: << pipeline.event.github.pull_request.base.sha >>
CBX_FORK_PR: << pipeline.parameters.fork_pr >>
steps:
- checkout
- run:
Expand Down Expand Up @@ -81,7 +90,8 @@ workflows:
# Semantic CI boundary: release-integration micro PRs target port/upstream-*.
# A micro-named head aimed directly at main still receives full hosted CI.
# Guard the PR-only value behind event.name so push pipelines compile safely.
when: pipeline.event.name == "push" or (pipeline.event.name == "pull_request" and not (pipeline.event.github.pull_request.base.ref starts-with "port/upstream-"))
# API pipelines run only with fork_pr set (the fork-ci-mirror workflow).
when: pipeline.event.name == "push" or (pipeline.event.name == "pull_request" and not (pipeline.event.github.pull_request.base.ref starts-with "port/upstream-")) or pipeline.parameters.fork_pr != ""
jobs:
- pr-check:
filters:
Expand Down
21 changes: 17 additions & 4 deletions .github/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,23 @@ build one:
with this project's environment variables.
2. Add the `ci:run` label. .github/workflows/fork-ci-mirror.yml pushes
exactly that head commit to ci/pr-<number> in this repository.
3. CircleCI builds the ci/pr-<number> push. The trigger gate in
scripts/circleci-pr-common.ps1 lets those branches through, and the
docs-only gate finds the PR base from the number. The check lands on the
same commit, so it shows on the fork PR.
3. The same workflow starts a CircleCI pipeline through the trigger API,
with the config from main and the code from ci/pr-<number>. A plain push
is not enough: CircleCI ignores a branch push that brings no new commits,
and the fork head is already in this repository through its pull ref.
The pipeline's fork_pr parameter lets it through the trigger gate in
scripts/circleci-pr-common.ps1, and the docs-only gate finds the PR base
from the number. The check lands on the same commit, so it shows on the
fork PR.

One-time setup for step 3:

- Actions secret CIRCLECI_TOKEN: a CircleCI personal API token (User
Settings -> Personal API Tokens) from a user with access to this project.
- Actions variable CIRCLECI_PROJECT_SLUG: Project Settings -> Overview, for
example circleci/<org-id>/<project-id>.
- Actions variable CIRCLECI_PIPELINE_DEFINITION_ID: Project Settings ->
Pipelines, the id of the pipeline that runs .circleci/config.yml.

Any new push to the fork PR removes the label; re-add it after reviewing the
new commits. Closing the PR removes the mirror branch. GITHUB_TOKEN cannot
Expand Down
41 changes: 35 additions & 6 deletions .github/workflows/fork-ci-mirror.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,17 @@ name: Fork PR CI mirror
# CircleCI GitHub App pipelines never fire for pull requests from forks, so
# fork PRs used to merge with no hosted Windows check. A maintainer approves a
# fork PR's current head by adding the `ci:run` label. This workflow then
# copies exactly that commit to ci/pr-<number> in this repository. CircleCI
# builds the push (scripts/circleci-pr-common.ps1 lets ci/pr-<number> through),
# and its check lands on the same commit, so it shows on the fork PR.
# copies exactly that commit to ci/pr-<number> in this repository and starts a
# CircleCI pipeline for it through the trigger API (secret CIRCLECI_TOKEN,
# variables CIRCLECI_PROJECT_SLUG and CIRCLECI_PIPELINE_DEFINITION_ID). The
# check lands on the same commit, so it shows on the fork PR.
#
# Safety:
# - pull_request_target runs this file from the base branch, never the fork's
# copy. No step checks out, installs, or executes fork code here; the job
# only moves a git ref. The fork code runs later on CircleCI, which is the
# reason for the label gate.
# only moves a git ref and calls the CircleCI API. The fork code runs later
# on CircleCI, which is the reason for the label gate. CIRCLECI_TOKEN is
# exposed only to that API step, never to fork code.
# - Only users with triage access or higher can add labels.
# - Any new push to the fork PR removes the label, so every new head needs a
# fresh maintainer approval before CircleCI builds it.
Expand Down Expand Up @@ -60,7 +62,34 @@ jobs:
test "$(git rev-parse FETCH_HEAD)" = "$HEAD_SHA"
# The mirror branch belongs to this workflow; a re-approval replaces it.
git -c http.extraheader="$auth" push --quiet --force origin "${HEAD_SHA}:refs/heads/ci/pr-${PR_NUMBER}"
echo "Mirrored ${HEAD_SHA} to ci/pr-${PR_NUMBER}; CircleCI pr-check will report on this commit." >> "$GITHUB_STEP_SUMMARY"
echo "Mirrored ${HEAD_SHA} to ci/pr-${PR_NUMBER}." >> "$GITHUB_STEP_SUMMARY"

# CircleCI ignores a branch push that brings no new commits, and the
# fork head is already in this repository through its pull ref. So the
# pipeline is started through the trigger API: config from main (the
# fork cannot change the CI definition), code from ci/pr-<number>.
- name: Start the CircleCI pipeline
env:
CIRCLECI_TOKEN: ${{ secrets.CIRCLECI_TOKEN }}
PROJECT_SLUG: ${{ vars.CIRCLECI_PROJECT_SLUG }}
DEFINITION_ID: ${{ vars.CIRCLECI_PIPELINE_DEFINITION_ID }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ -z "$CIRCLECI_TOKEN" ] || [ -z "$PROJECT_SLUG" ] || [ -z "$DEFINITION_ID" ]; then
echo "::error::Set secret CIRCLECI_TOKEN and variables CIRCLECI_PROJECT_SLUG and CIRCLECI_PIPELINE_DEFINITION_ID (see .github/CI.md)."
exit 1
fi
body=$(jq -n --arg def "$DEFINITION_ID" --arg pr "$PR_NUMBER" \
'{definition_id: $def, config: {branch: "main"}, checkout: {branch: ("ci/pr-" + $pr)}, parameters: {fork_pr: $pr}}')
status=$(curl -sS -o response.json -w '%{http_code}' -X POST \
-H "Circle-Token: ${CIRCLECI_TOKEN}" -H 'Content-Type: application/json' \
--data "$body" "https://circleci.com/api/v2/project/${PROJECT_SLUG}/pipeline/run")
if [ "$status" != "201" ]; then
echo "::error::CircleCI trigger returned HTTP ${status}: $(jq -r '.message // empty' response.json)"
exit 1
fi
echo "CircleCI pipeline #$(jq -r .number response.json) started for ci/pr-${PR_NUMBER}; pr-check will report on the fork PR's head commit." >> "$GITHUB_STEP_SUMMARY"

revoke:
name: Require re-approval after a new push
Expand Down
17 changes: 12 additions & 5 deletions scripts/circleci-pr-common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ function Get-TriggerGateDecision {
param(
[AllowEmptyString()][string]$BudgetMode,
[AllowEmptyString()][string]$Branch,
[AllowEmptyString()][string]$PrUrl
[AllowEmptyString()][string]$PrUrl,
[AllowEmptyString()][string]$ForkPr = ''
)

# Gate 1 - budget: CI_BUDGET_MODE=off is the emergency stop
Expand All @@ -55,7 +56,7 @@ function Get-TriggerGateDecision {
# those pushes run the checks too.
$isPr = -not [string]::IsNullOrWhiteSpace($PrUrl)
$isMainPush = $Branch -in @('main', 'master')
$isForkMirror = -not [string]::IsNullOrWhiteSpace((Get-ForkMirrorPrNumber -Branch $Branch))
$isForkMirror = -not [string]::IsNullOrWhiteSpace((Get-ForkMirrorPrNumber -Branch $Branch -ForkPr $ForkPr))
if (-not $isPr -and -not $isMainPush -and -not $isForkMirror) {
return [pscustomobject]@{
Skip = $true
Expand All @@ -71,12 +72,18 @@ function Get-TriggerGateDecision {

<#
.SYNOPSIS
PR number of a ci/pr-<number> fork-mirror branch, or '' for any other
branch. The fork-ci-mirror workflow is the only writer of these branches.
PR number of an approved fork mirror, or '' when the pipeline is not one.
The fork-ci-mirror workflow is the only writer of ci/pr-<number>
branches, and the only caller that sets the fork_pr pipeline parameter
(passed in as ForkPr) through the CircleCI trigger API.
#>
function Get-ForkMirrorPrNumber {
param([AllowEmptyString()][string]$Branch)
param(
[AllowEmptyString()][string]$Branch,
[AllowEmptyString()][string]$ForkPr = ''
)

if ($ForkPr -match '^[1-9]\d*$') { return $ForkPr }
if ($Branch -match '^ci/pr-([1-9]\d*)$') { return $Matches[1] }
return ''
}
Expand Down
8 changes: 6 additions & 2 deletions scripts/circleci-pr-gates.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ param(
# push pipelines).
[AllowEmptyString()][string]$PrBaseSha = $env:CBX_PR_BASE_SHA,

# Pipeline parameter fork_pr, exported as CBX_FORK_PR. Set only on API
# pipelines started by .github/workflows/fork-ci-mirror.yml.
[AllowEmptyString()][string]$ForkPr = $env:CBX_FORK_PR,

# CIRCLE_SHA1.
[AllowEmptyString()][string]$Sha = $env:CIRCLE_SHA1,

Expand Down Expand Up @@ -74,7 +78,7 @@ function Invoke-GateHalt {
}

# Gates 1 and 2: budget emergency stop, then PR/main-master scope.
$trigger = Get-TriggerGateDecision -BudgetMode $BudgetMode -Branch $Branch -PrUrl $PrUrl
$trigger = Get-TriggerGateDecision -BudgetMode $BudgetMode -Branch $Branch -PrUrl $PrUrl -ForkPr $ForkPr
if ($trigger.Skip) {
Invoke-GateHalt -Reason $trigger.Reason
exit 0
Expand All @@ -94,7 +98,7 @@ if ($isMainPush) {
# PR association without a populated event value (e.g. api trigger):
# resolve the base from the public GitHub pulls API.
try {
$prNumber = Get-ForkMirrorPrNumber -Branch $Branch
$prNumber = Get-ForkMirrorPrNumber -Branch $Branch -ForkPr $ForkPr
if ($PrUrl -match '/pull/(\d+)') { $prNumber = $Matches[1] }
if ([string]::IsNullOrWhiteSpace($prNumber)) { throw 'PR number not available from pipeline values.' }
if ([string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_USERNAME) -or [string]::IsNullOrWhiteSpace($env:CIRCLE_PROJECT_REPONAME)) {
Expand Down
9 changes: 9 additions & 0 deletions scripts/circleci-pr.tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ foreach ($branch in @('ci/pr-', 'ci/pr-0', 'ci/pr-12x', 'ci/pr-7/extra', 'x/ci/p
}
Assert-Equal (Get-ForkMirrorPrNumber -Branch 'ci/pr-750') '750' 'mirror PR number parsed'
Assert-Equal (Get-ForkMirrorPrNumber -Branch 'main') '' 'non-mirror branch has no PR number'
$decision = Get-TriggerGateDecision -BudgetMode 'normal' -Branch 'pull/750/head' -PrUrl '' -ForkPr '750'
Assert-True (-not $decision.Skip) 'API fork pipeline with fork_pr runs whatever the branch name'
$decision = Get-TriggerGateDecision -BudgetMode 'off' -Branch '' -PrUrl '' -ForkPr '750'
Assert-True $decision.Skip 'budget off wins over fork_pr'
foreach ($forkPr in @('0', 'abc', '7 ', '-1', '12x')) {
$decision = Get-TriggerGateDecision -BudgetMode 'normal' -Branch 'feature/x' -PrUrl '' -ForkPr $forkPr
Assert-True $decision.Skip "malformed fork_pr '$forkPr' skips"
}
Assert-Equal (Get-ForkMirrorPrNumber -Branch 'ci/pr-12' -ForkPr '750') '750' 'fork_pr wins over the branch name'

Write-Host '==> Hosted Node provisioning guard'
$prRunnerText = Get-Content -Raw -LiteralPath (Join-Path $scriptRoot 'run-circleci-pr-check.ps1')
Expand Down