ci(e2e): run fork-PR e2e via privileged workflow_run (#3257)

Fork PRs cannot push images (pull_request runs from a fork receive no
secrets), so `make image` pushed anonymously and died with `denied`.
That failed every build, collapsed the build->finalize->e2e chain, and
left the required "E2E Tests" job skipped — which branch protection
treats as satisfied, so an approved fork PR could merge with e2e never
having run.

pull-requests.yaml (fork PRs):
- build / build-talos / finalize export images to per-image OCI archives
  (OCI_EXPORT_DIR) and upload them instead of pushing, so the red
  "denied" build wall disappears and the chain no longer collapses.
- the in-tree e2e job is renamed off "E2E Tests" and guarded to
  same-repo PRs; a new e2e-report job concludes the required "E2E Tests"
  context as an explicit check-run (success on a docs-only PR or when the
  in-tree e2e passed, failure otherwise). A skipped job can no longer
  satisfy the required check.

e2e-fork.yaml (new, privileged workflow_run from the default branch):
- resolves the fork PR, opens a pending "E2E Tests" check-run, and
  fails-closed if the fork build failed;
- pushes the pre-built image archives to OCIR by digest and flux-pushes
  the digest-pinned packages tree — the OCIR token is live only for
  trusted binaries (git/skopeo/flux/yq) over data, never for `make` or
  any fork-authored script;
- runs the e2e suite against the pushed closure with no push token and
  concludes the "E2E Tests" check-run on the PR head SHA.

Fork build code never receives registry credentials; the fork's images
and test scripts run only in the credential-less, ephemeral e2e job.

Refs: #3257

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov DaniilandClaude Opus 4.8 committed 2026-07-29 17:47:25 +05:00
1 parent 529efa7e01
commit e0ac2f9e12
2 files changed
+514 -6

No files matched your search

+389
View File
@@ -0,0 +1,389 @@
name: E2E (fork)
# Privileged half of the fork-PR e2e split (#3257).
#
# A fork PR's `pull_request` run (pull-requests.yaml) carries no registry
# credentials, so it builds every image to an OCI archive and uploads it as an
# artifact instead of pushing (OCI_EXPORT_DIR). This workflow runs from the
# DEFAULT branch after that run completes — so it DOES have secrets — and:
# 1. pushes the pre-built image archives to OCIR BY DIGEST and flux-pushes the
# digest-pinned packages tree (job `publish`); and
# 2. runs the e2e suite against them and reports the required "E2E Tests"
# check-run back on the PR head SHA (jobs `e2e` + `report`).
#
# Security boundary: the untrusted fork code produced only opaque artifacts. The
# OCIR push token is live ONLY in `publish`, which runs trusted binaries
# (git/skopeo/flux/yq) over data — never `make` or any fork-authored script. The
# fork's images and test scripts run only in `e2e`, which has NO push token and
# runs in an ephemeral sandbox. This is why the split exists instead of a
# pull_request_target run that would hand fork code the credentials directly.
#
# Same-repo PRs are handled entirely by pull-requests.yaml; this workflow no-ops
# for them (see the `resolve` job guard).
on:
workflow_run:
workflows: ["Pull Request"]
types: [completed]
# Cancel a stale privileged run when the fork pushes again (a newer Pull Request
# run for the same fork branch supersedes it). Keyed by fork repo + branch since
# the PR number is not known at workflow level.
concurrency:
group: e2e-fork-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
env:
REGISTRY: iad.ocir.io/idyksih5sir9/cozystack
permissions:
contents: read
jobs:
# Decide whether this is a fork PR that needs e2e, resolve its PR number and
# head SHA, and open the pending "E2E Tests" check-run so the PR is not
# mergeable until we conclude it. Fail-closed if the fork build failed;
# green-and-stop for docs-only fork PRs (no pr-patch artifact = nothing built).
resolve:
name: Resolve fork PR
# Only fork PRs: head_repository differs from the base repository. Same-repo
# PRs get "E2E Tests" from pull-requests.yaml's e2e-report job instead.
if: >-
${{ github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.head_repository.full_name != github.event.workflow_run.repository.full_name }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
actions: read
outputs:
should_run: ${{ steps.r.outputs.should_run }}
pr_number: ${{ steps.r.outputs.pr_number }}
head_sha: ${{ steps.r.outputs.head_sha }}
run_id: ${{ steps.r.outputs.run_id }}
check_run_id: ${{ steps.r.outputs.check_run_id }}
steps:
- name: Resolve and open check-run
id: r
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
RUN_ID: ${{ github.event.workflow_run.id }}
CONCLUSION: ${{ github.event.workflow_run.conclusion }}
with:
script: |
const { HEAD_SHA, RUN_ID, CONCLUSION } = process.env;
const { owner, repo } = context.repo;
// Validate the SHA before it flows into a check-run / image tag.
if (!/^[0-9a-f]{40}$/.test(HEAD_SHA)) {
core.setFailed(`unexpected head_sha '${HEAD_SHA}'`);
return;
}
core.setOutput('head_sha', HEAD_SHA);
core.setOutput('run_id', String(RUN_ID));
const conclude = async (conclusion, summary) => {
const cr = await github.rest.checks.create({
owner, repo, name: 'E2E Tests', head_sha: HEAD_SHA,
status: 'completed', conclusion,
output: { title: 'E2E Tests', summary },
});
return cr.data.id;
};
// Fork build did not succeed → fail the required check (fail-closed).
if (CONCLUSION !== 'success') {
await conclude('failure', `Pull Request workflow concluded '${CONCLUSION}'; e2e not run`);
core.setOutput('should_run', 'false');
return;
}
// Resolve the PR number for this commit (workflow_run.pull_requests
// is empty for forks). Validate it is a bare integer before it flows
// into a checkout ref.
const assoc = await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner, repo, commit_sha: HEAD_SHA,
});
const pr = assoc.data.find(p => p.state === 'open') || assoc.data[0];
const prNumber = pr ? String(pr.number) : '';
if (!/^[0-9]+$/.test(prNumber)) {
await conclude('failure', `could not resolve an open PR for ${HEAD_SHA}`);
core.setOutput('should_run', 'false');
return;
}
core.setOutput('pr_number', prNumber);
// Docs-only fork PR: the build/finalize chain was skipped, so no
// pr-patch artifact exists — nothing to test, conclude success.
const arts = await github.paginate(
github.rest.actions.listWorkflowRunArtifacts,
{ owner, repo, run_id: RUN_ID, per_page: 100 },
);
if (!arts.some(a => a.name === 'pr-patch')) {
await conclude('success', 'docs-only PR — e2e not required');
core.setOutput('should_run', 'false');
return;
}
// Real run: open an in-progress check-run; `report` concludes it.
const cr = await github.rest.checks.create({
owner, repo, name: 'E2E Tests', head_sha: HEAD_SHA,
status: 'in_progress',
output: { title: 'E2E Tests', summary: 'Running e2e in the privileged fork workflow…' },
});
core.setOutput('check_run_id', String(cr.data.id));
core.setOutput('should_run', 'true');
# Push the fork-built image archives to OCIR by digest and flux-push the
# digest-pinned packages tree. Only trusted binaries touch the fork's content
# while the OCIR token is live — no `make`/fork script runs here.
publish:
name: Push fork images + packages artifact
needs: [resolve]
if: ${{ needs.resolve.outputs.should_run == 'true' }}
runs-on: oracle-vm-4cpu-16gb-x86-64
timeout-minutes: 40
permissions:
contents: read
packages: write
actions: read
outputs:
packages_digest: ${{ steps.push.outputs.packages_digest }}
steps:
- name: Set up toolchain (skopeo, flux)
run: |
command -v skopeo >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq skopeo; }
command -v flux >/dev/null || curl -fsSL https://fluxcd.io/install.sh | sudo bash
# The base repo's own merge ref — safe to fetch, not fork-controlled code
# execution. pr.patch is applied as DATA below; nothing from the tree runs.
- name: Checkout PR merge ref
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: refs/pull/${{ needs.resolve.outputs.pr_number }}/merge
fetch-depth: 0
persist-credentials: false
- name: Download pr.patch
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: pr-patch
path: _out/assets
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ needs.resolve.outputs.run_id }}
- name: Apply pr.patch (reconstruct digest-pinned tree)
run: git apply --3way _out/assets/pr.patch
- name: Download OCI image archives
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
pattern: pr-oci-*
merge-multiple: true
path: _out/oci
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ needs.resolve.outputs.run_id }}
- name: Login to OCIR
env:
OCIR_USER: ${{ secrets.OCIR_USER }}
OCIR_TOKEN: ${{ secrets.OCIR_TOKEN }}
run: |
# skopeo (image copies) and flux (artifact push, via the docker
# keychain) each need OCIR auth. Token via stdin, never argv.
echo "$OCIR_TOKEN" | skopeo login "${REGISTRY%%/*}" -u "$OCIR_USER" --password-stdin
echo "$OCIR_TOKEN" | docker login "${REGISTRY%%/*}" -u "$OCIR_USER" --password-stdin
- name: Push images by digest + packages artifact
id: push
env:
# Tag is cosmetic — consumers resolve by @sha256 digest (baked into
# pr.patch) — but keep the pr-<N>-<sha> scheme for discoverability and
# retention. Both values are validated in `resolve`.
IMAGE_TAG: pr-${{ needs.resolve.outputs.pr_number }}-${{ needs.resolve.outputs.head_sha }}
HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
run: |
set -eu
shopt -s nullglob
for f in _out/oci/*.oci.tar; do
name="$(basename "$f" .oci.tar)"
echo "Pushing $name → ${REGISTRY}/${name}:${IMAGE_TAG}"
# skopeo preserves the manifest digest bit-for-bit, so the
# ${REGISTRY}/${name}@sha256:… refs in pr.patch resolve.
skopeo copy --preserve-digests "oci-archive:$f" "docker://${REGISTRY}/${name}:${IMAGE_TAG}"
done
# Push the fully-patched packages tree — the OCI artifact the in-cluster
# operator pulls. This uploads data; it runs no code from the tree.
flux push artifact "oci://${REGISTRY}/cozystack-packages:${IMAGE_TAG}" \
--path=packages \
--source=https://github.com/cozystack/cozystack \
--revision="fork-pr:${HEAD_SHA}" 2>&1 | tee _push.log
digest="$(awk -F@ '/artifact successfully pushed/ {print $2}' _push.log)"
[ -n "$digest" ] || { echo "::error::failed to capture packages artifact digest"; exit 1; }
echo "packages_digest=${digest}" >> "$GITHUB_OUTPUT"
echo "Pushed packages artifact @${digest}"
# Run the e2e suite against the pushed closure. NO OCIR push token here — the
# sandbox pulls images anonymously; the fork's images/scripts run only in this
# ephemeral job. Mirrors pull-requests.yaml's in-tree e2e (full suite; a fork
# PR has no reliable cross-workflow diff to scope TIA against).
e2e:
name: E2E (fork run)
needs: [resolve, publish]
if: ${{ needs.resolve.outputs.should_run == 'true' }}
runs-on: oracle-vm-32cpu-128gb-x86-64
timeout-minutes: 180
permissions:
contents: read
packages: read
actions: read
steps:
- name: Checkout PR merge ref
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: refs/pull/${{ needs.resolve.outputs.pr_number }}/merge
fetch-depth: 0
persist-credentials: false
- name: Set up toolchain (yq)
run: |
command -v yq >/dev/null || { sudo curl -sSL -o /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 && sudo chmod +x /usr/local/bin/yq; }
- name: Download Talos image
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: talos-image
path: _out/assets
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ needs.resolve.outputs.run_id }}
- name: Download pr.patch
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: pr-patch
path: _out/assets
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ needs.resolve.outputs.run_id }}
- name: Apply pr.patch
run: git apply --3way _out/assets/pr.patch
# Point the installer at the packages artifact `publish` pushed (the
# artifact carries no self-digest, so pin it on the values we install).
- name: Pin installer to pushed packages artifact
env:
PACKAGES_DIGEST: ${{ needs.publish.outputs.packages_digest }}
run: |
yq -i ".cozystackOperator.platformSourceUrl = \"oci://${REGISTRY}/cozystack-packages\"" packages/core/installer/values.yaml
yq -i ".cozystackOperator.platformSourceRef = \"digest=${PACKAGES_DIGEST}\"" packages/core/installer/values.yaml
- name: Set sandbox ID
run: echo "SANDBOX_NAME=cozy-e2e-sandbox-$(echo "${GITHUB_REPOSITORY}:${GITHUB_WORKFLOW}:fork-${{ needs.resolve.outputs.pr_number }}" | sha256sum | cut -c1-10)" >> "$GITHUB_ENV"
- name: Prepare workspace
run: |
rm -rf "/tmp/$SANDBOX_NAME"
cp -r "${{ github.workspace }}" "/tmp/$SANDBOX_NAME"
- name: Prepare environment
run: |
cd "/tmp/$SANDBOX_NAME"
attempt=0
until make SANDBOX_NAME=$SANDBOX_NAME prepare-env; do
attempt=$((attempt + 1))
if [ $attempt -ge 3 ]; then echo "Attempt $attempt failed, exiting..."; exit 1; fi
echo "Attempt $attempt failed, retrying..."
done
- name: Install Cozystack into sandbox
run: |
cd "/tmp/$SANDBOX_NAME"
if ! make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME install-cozystack; then
echo "❌ Install failed (no retry — diagnostics below)"
echo "::group::HelmRelease status"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A -o wide 2>&1 | tail -100' || true
echo "::endgroup::"
echo "::group::not-Ready HelmReleases"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A --no-headers 2>/dev/null | awk "\$4 != \"True\" {print \$1\" \"\$2}"' \
| while read -r ns name; do
echo "--- $ns/$name ---"
docker exec "$SANDBOX_NAME" sh -c "kubectl describe hr '$name' -n '$ns' 2>&1 | tail -50" || true
done
echo "::endgroup::"
exit 1
fi
- name: Run OpenAPI tests
run: |
cd "/tmp/$SANDBOX_NAME"
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME test-openapi
- name: Run E2E tests (full suite)
run: |
cd "/tmp/$SANDBOX_NAME"
apps_list=$(ls hack/e2e-apps/*.bats | xargs -n1 basename | cut -d. -f1)
failed_tests=""
for app in $apps_list; do
echo "::group::Testing $app"
if make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME test-apps-$app; then
echo "::endgroup::"; echo "✅ $app passed"
else
echo "::endgroup::"; echo "❌ $app failed"
failed_tests="$failed_tests $app"
echo "::group::Diagnostics for $app"
docker exec "$SANDBOX_NAME" sh -c 'kubectl get hr -A -o wide 2>&1 | tail -50' || true
docker exec "$SANDBOX_NAME" sh -c 'kubectl get events -A --sort-by=.lastTimestamp 2>&1 | tail -30' || true
echo "::endgroup::"
fi
done
if [ -n "$failed_tests" ]; then echo "❌ Failed:$failed_tests"; exit 1; fi
echo "✅ All E2E tests passed"
- name: Collect report
if: always()
run: |
cd "/tmp/$SANDBOX_NAME"
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME collect-report || true
- name: Upload cozyreport.tgz
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: cozyreport
path: /tmp/${{ env.SANDBOX_NAME }}/_out/cozyreport.tgz
if-no-files-found: warn
- name: Tear down sandbox
if: always()
run: make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME delete || true
- name: Remove workspace
if: always()
run: rm -rf "/tmp/$SANDBOX_NAME"
# Conclude the required "E2E Tests" check-run opened by `resolve`. Always runs
# (so a failed publish/e2e still turns the check red rather than leaving it
# stuck in-progress). success only when the e2e job passed.
report:
name: Report fork E2E status
needs: [resolve, publish, e2e]
if: ${{ always() && needs.resolve.outputs.should_run == 'true' }}
runs-on: ubuntu-latest
permissions:
checks: write
steps:
- name: Conclude "E2E Tests" check-run
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
CHECK_RUN_ID: ${{ needs.resolve.outputs.check_run_id }}
PUBLISH_RESULT: ${{ needs.publish.result }}
E2E_RESULT: ${{ needs.e2e.result }}
HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
with:
script: |
const { CHECK_RUN_ID, PUBLISH_RESULT, E2E_RESULT, HEAD_SHA } = process.env;
const { owner, repo } = context.repo;
let conclusion, summary;
if (E2E_RESULT === 'success') {
conclusion = 'success';
summary = 'fork e2e passed (privileged run)';
} else if (PUBLISH_RESULT !== 'success') {
conclusion = 'failure';
summary = `image/artifact publish did not succeed (result: ${PUBLISH_RESULT})`;
} else {
conclusion = 'failure';
summary = `fork e2e did not pass (result: ${E2E_RESULT})`;
}
if (CHECK_RUN_ID) {
await github.rest.checks.update({
owner, repo, check_run_id: Number(CHECK_RUN_ID),
status: 'completed', conclusion,
output: { title: 'E2E Tests', summary },
});
} else {
await github.rest.checks.create({
owner, repo, name: 'E2E Tests', head_sha: HEAD_SHA,
status: 'completed', conclusion,
output: { title: 'E2E Tests', summary },
});
}
core.info(`E2E Tests → ${conclusion}: ${summary}`);
+125 -6
View File
@@ -189,7 +189,9 @@ jobs:
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
- name: Build ${{ matrix.pkg }}
run: make -C "$PKG" image
run: |
mkdir -p _out/oci
make -C "$PKG" image
env:
PKG: ${{ matrix.pkg }}
DOCKER_CONFIG: ${{ runner.temp }}/.docker
@@ -202,6 +204,12 @@ jobs:
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
PUBLISH_VERSIONED: '0'
PUBLISH_FLOATING: '0'
# Fork PRs get no registry credentials, so instead of pushing, build
# each image to _out/oci/<name>.oci.tar and upload it below; the
# privileged e2e-fork.yaml workflow_run pushes them by digest. Empty on
# same-repo PRs → normal push path. Setting it also forces PUSH/LOAD
# off in common-envs.mk (#3257).
OCI_EXPORT_DIR: ${{ github.event.pull_request.head.repo.fork && '_out/oci' || '' }}
- name: Capture digest patch fragment
id: frag
env:
@@ -221,6 +229,15 @@ jobs:
name: pr-patch-fragment-${{ steps.frag.outputs.safe }}
path: _out/fragments/${{ steps.frag.outputs.safe }}.patch
if-no-files-found: error
# Fork PR only: the images were exported to OCI archives instead of pushed
# (OCI_EXPORT_DIR above). Upload them for e2e-fork.yaml to push by digest.
- name: Upload OCI image archives (fork PR)
if: ${{ github.event.pull_request.head.repo.fork }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-oci-${{ steps.frag.outputs.safe }}
path: _out/oci/*.oci.tar
if-no-files-found: error
# Talos is its own leg: the nocloud disk and the installer tarball are heavy
# and shared through _out/assets, so the talos image and the disk are built in
@@ -269,13 +286,17 @@ jobs:
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
- name: Build Talos image (matchbox + installer tarball)
run: make -C packages/core/talos image
run: |
mkdir -p _out/oci
make -C packages/core/talos image
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
BUILDER: ${{ steps.buildx.outputs.name }}
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
PUBLISH_VERSIONED: '0'
PUBLISH_FLOATING: '0'
# Fork PR: export to OCI archive instead of pushing (see build job).
OCI_EXPORT_DIR: ${{ github.event.pull_request.head.repo.fork && '_out/oci' || '' }}
- name: Build Talos nocloud disk
run: make -C packages/core/talos talos-nocloud
- name: Capture Talos patch fragment
@@ -289,6 +310,14 @@ jobs:
name: pr-patch-fragment-talos
path: _out/fragments/talos.patch
if-no-files-found: error
# Fork PR only: upload the Talos image OCI archive for e2e-fork.yaml.
- name: Upload Talos OCI image archive (fork PR)
if: ${{ github.event.pull_request.head.repo.fork }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-oci-talos
path: _out/oci/*.oci.tar
if-no-files-found: error
- name: Upload Talos image
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
@@ -421,16 +450,37 @@ jobs:
buildkitd-config: hack/buildkitd.toml
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
# Builds the operator image AND flux-pushes the now-fully-patched packages
# tree, then patches the installer's platformSourceRef to that artifact.
# Same-repo PR: build the operator image AND flux-push the now-fully-patched
# packages tree, then patch the installer's platformSourceRef to that
# artifact. Fork PR: no registry creds — build ONLY the operator image to an
# OCI archive (image-packages/chart, which push, are skipped) and upload it;
# e2e-fork.yaml pushes the operator + flux-pushes the packages tree and
# bakes platformSourceRef there. The overlay above already ran, so the
# pr.patch assembled below carries the fully digest-pinned tree either way.
- name: Build installer (operator + packages artifact)
run: make -C packages/core/installer image
run: |
mkdir -p _out/oci
if [ "$IS_FORK" = "true" ]; then
make -C packages/core/installer pre-checks image-operator
else
make -C packages/core/installer image
fi
env:
DOCKER_CONFIG: ${{ runner.temp }}/.docker
BUILDER: ${{ steps.buildx.outputs.name }}
IMAGE_TAG: pr-${{ github.event.pull_request.number }}-${{ github.event.pull_request.head.sha }}
PUBLISH_VERSIONED: '0'
PUBLISH_FLOATING: '0'
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
OCI_EXPORT_DIR: ${{ github.event.pull_request.head.repo.fork && '_out/oci' || '' }}
# Fork PR only: upload the operator image OCI archive for e2e-fork.yaml.
- name: Upload installer OCI image archive (fork PR)
if: ${{ github.event.pull_request.head.repo.fork }}
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: pr-oci-installer
path: _out/oci/*.oci.tar
if-no-files-found: error
- name: Assemble pr.patch
run: |
mkdir -p _out/assets
@@ -515,8 +565,16 @@ jobs:
core.setOutput('disk_id', diskId);
# In-tree e2e for SAME-REPO PRs. Deliberately NOT named "E2E Tests": the
# required "E2E Tests" context is an explicit check-run concluded by the
# `e2e-report` job (non-fork) or by e2e-fork.yaml (fork). A required *job* that
# skips counts as PASSED by branch protection, which is exactly how fork PRs
# used to merge with e2e never run (#3257); routing the required context
# through a check-run removes that skip-to-green path. Fork PRs skip this job
# (they have no pushed images to pull) and run e2e in the privileged
# workflow_run instead.
e2e:
name: "E2E Tests"
name: "E2E (in-tree)"
# The sandbox boots three QEMU guests (hack/e2e-prepare-cluster.bats), each
# with `-smp 8 -m 24576`, so the guests alone demand 24 vCPU and 72 GiB.
# On the 24-vCPU runner that is a 1:1 CPU commit with zero host headroom for
@@ -543,8 +601,14 @@ jobs:
# enters through resolve_assets only when a maintainer explicitly adds
# full-e2e; without that label the immutable rc's already-green suite is not
# repeated. The labeled trigger above makes that post-open opt-in start a run.
#
# Fork PRs never enter here: their build exports OCI archives instead of
# pushing images, so there is no closure for this job to test. e2e-fork.yaml
# runs the suite for them from the privileged workflow_run and concludes the
# required "E2E Tests" status; for same-repo PRs e2e-report concludes it.
if: |
!cancelled()
&& !github.event.pull_request.head.repo.fork
&& (
needs.finalize.result == 'success'
|| (
@@ -808,3 +872,58 @@ jobs:
- name: Tear down sandbox
if: always()
run: make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME delete || true
# Report the required "E2E Tests" status as an explicit check-run rather than
# relying on a job named "E2E Tests". A required *job* that is skipped counts
# as PASSED by branch protection — desired for docs-only PRs, but the exact
# hole that let fork PRs merge with e2e never run (#3257). This job always runs
# on same-repo PRs and concludes "E2E Tests" deterministically:
# • plan failed → failure
# • docs-only PR (code!=true)→ success (e2e not required)
# • in-tree e2e succeeded → success
# • otherwise (skipped/failed/cancelled with code changes) → failure
# Fork PRs skip this job; e2e-fork.yaml concludes "E2E Tests" for them, so a
# fork PR stays "Expected"/not-mergeable until the privileged run reports.
e2e-report:
name: "Report E2E status"
needs: ["plan", "e2e"]
if: ${{ always() && !github.event.pull_request.head.repo.fork }}
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
steps:
- name: Conclude "E2E Tests" check-run
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
env:
PLAN_RESULT: ${{ needs.plan.result }}
CODE: ${{ needs.plan.outputs.code }}
E2E_RESULT: ${{ needs.e2e.result }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
with:
script: |
const { PLAN_RESULT, CODE, E2E_RESULT, HEAD_SHA } = process.env;
let conclusion, summary;
if (PLAN_RESULT !== 'success') {
conclusion = 'failure';
summary = `plan job did not succeed (result: ${PLAN_RESULT})`;
} else if (CODE !== 'true') {
conclusion = 'success';
summary = 'docs-only PR — e2e not required';
} else if (E2E_RESULT === 'success') {
conclusion = 'success';
summary = 'in-tree e2e passed';
} else {
conclusion = 'failure';
summary = `in-tree e2e did not pass (result: ${E2E_RESULT})`;
}
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'E2E Tests',
head_sha: HEAD_SHA,
status: 'completed',
conclusion,
output: { title: 'E2E Tests', summary },
});
core.info(`E2E Tests → ${conclusion}: ${summary}`);