mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 14:02:56 +09:00
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:
1 parent
529efa7e01
commit
e0ac2f9e12
2 files changed
+514
-6
No files matched your search
@@ -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}`);
|
||||
@@ -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}`);
|
||||
Reference in new issue
Block a user