diff --git a/.github/workflows/eden-trusted.yml b/.github/workflows/eden-trusted.yml new file mode 100644 index 00000000000..f0abbb31218 --- /dev/null +++ b/.github/workflows/eden-trusted.yml @@ -0,0 +1,268 @@ +# Copyright (c) 2025, Zededa, Inc. +# SPDX-License-Identifier: Apache-2.0 +--- +name: Run Eden + +on: # yamllint disable-line rule:truthy + workflow_run: + workflows: ["PR Gate"] + types: [completed] + +permissions: + contents: read + actions: read + statuses: write + +env: + EDEN_IN_PR_STATUS_TITLE_PREFIX: Eden Runner + EDEN_IN_PR_DETAILS_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + EDEN_PARENT_JOB_TITLE_PREFIX: Eden Tests + RUN_CONTEXT_FILE: run-context.json + +jobs: + context: + name: Setup Context from Gate + if: ${{ github.event.workflow_run.conclusion == 'success' }} + runs-on: ubuntu-latest + outputs: + pr_id: ${{ steps.extract.outputs.pr_id }} + original_run_id: ${{ steps.extract.outputs.original_run_id }} + pr_sha: ${{ steps.extract.outputs.pr_sha }} + hv: ${{ steps.extract.outputs.hv }} + arch: ${{ steps.extract.outputs.arch }} + platform: ${{ steps.extract.outputs.platform }} + eden_parent_job_title: ${{ steps.titles.outputs.eden_parent_job_title }} + eden_in_pr_status_title: ${{ steps.titles.outputs.eden_in_pr_status_title }} + gate_run_id: ${{ steps.extract.outputs.gate_run_id }} + gate_status_name: ${{ steps.extract.outputs.gate_status_name }} + skip_run: ${{ steps.check_gate.outputs.skip_run }} + steps: + - name: Download + uses: actions/download-artifact@v4 + with: + name: run-context + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Check if Gate Passed + id: check_gate + run: | + if [[ ! -f "$RUN_CONTEXT_FILE" ]]; then + echo "::error::Gate context file not found" + echo "skip_run=true" >> "$GITHUB_OUTPUT" + exit 1 + fi + # Check if the file contains only the "exit" command + if [[ $(cat "$RUN_CONTEXT_FILE") == "exit" ]]; then + echo "::error::Gate not satisfied, exiting" + echo "skip_run=true" >> "$GITHUB_OUTPUT" + else + echo "skip_run=false" >> "$GITHUB_OUTPUT" + fi + + - name: Verify + if: ${{ steps.check_gate.outputs.skip_run == 'false' }} + env: + RUN_CONTEXT_FILE: ${{ env.RUN_CONTEXT_FILE }} + REQUIRED_FIELDS: pr_id, original_run_id, pr_sha, hv, arch, platform, gate_run_id, gate_status_name + run: | + if [[ ! -f "$RUN_CONTEXT_FILE" ]]; then + echo "$RUN_CONTEXT_FILE file not found" + exit 1 + fi + cat "$RUN_CONTEXT_FILE" + if ! jq -e . $RUN_CONTEXT_FILE > /dev/null; then + echo "$RUN_CONTEXT_FILE is not a valid JSON file" + exit 1 + fi + # Now check for all required fields + has_all_fields=true + + # Split the CSV once; spaces in names are not expected, so keep tr + for field in $(echo "$REQUIRED_FIELDS" | tr ',' ' '); do + if ! jq -e --arg f "$field" 'has($f)' "$RUN_CONTEXT_FILE" >/dev/null; then + echo "Missing required field: $field" + has_all_fields=false + fi + done + + if [[ $has_all_fields == false ]]; then + echo "Run context is missing required fields" + exit 1 + fi + + - name: Extract + if: ${{ steps.check_gate.outputs.skip_run == 'false' }} + id: extract + env: + FIELDS: pr_id, original_run_id, pr_sha, hv, arch, platform, gate_run_id, gate_status_name + run: | + # Extract fields from the JSON file + for field in $(echo "$FIELDS" | tr ',' ' '); do + value=$(jq -r --arg f "$field" '.[$f]' $RUN_CONTEXT_FILE) + if [[ -z "$value" || "$value" == "null" ]]; then + echo "Field $field is empty or null" + exit 1 + fi + echo "$field=$value" >> "$GITHUB_OUTPUT" + done + + - name: Define Titles + if: ${{ steps.check_gate.outputs.skip_run == 'false' }} + id: titles + run: | + postfix="(${{ steps.extract.outputs.hv }}, ${{ steps.extract.outputs.arch }}, ${{ steps.extract.outputs.platform }})" + echo "eden_parent_job_title=${{ env.EDEN_PARENT_JOB_TITLE_PREFIX }} $postfix" >> "$GITHUB_OUTPUT" + echo "eden_in_pr_status_title=${{ env.EDEN_IN_PR_STATUS_TITLE_PREFIX }} $postfix" >> "$GITHUB_OUTPUT" + + check_secrets: + name: Check Secrets + if: github.event.workflow_run.conclusion == 'success' && needs.context.outputs.skip_run == 'false' + needs: context + runs-on: ubuntu-latest + steps: + - name: Check Dockerhub credentials + run: | + if [[ -z "${{ secrets.DOCKERHUB_PULL_USER }}" ]]; then + echo "Warning: DOCKERHUB_PULL_USER secret is not set. This may affect the ability to pull images from Docker Hub."; + fi + + status_ui: + name: Status UI + runs-on: ubuntu-latest + needs: context + if: needs.context.outputs.skip_run == 'false' + env: + EDEN_IN_PR_STATUS_TITLE: ${{ needs.context.outputs.eden_in_pr_status_title }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Switch Gate Status to Green + if: ${{ github.event.workflow_run.conclusion == 'success' }} + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + // Get new target URL for the Gate status that passes and called this workflow + const targetUrlPrefix = `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ needs.context.outputs.gate_run_id }}`; + // Find the job that triggered this workflow + const { data: jobs } = await github.rest.actions.listJobsForWorkflowRun({ + owner, repo, run_id: ${{ needs.context.outputs.gate_run_id }} + }); + // Print the jobs for debugging + console.log(jobs.jobs.map(j => j.name).join(', ')); + const job = jobs.jobs.find(j => j.name.startsWith('${{ needs.context.outputs.gate_status_name }}')); + const targetUrl = `${targetUrlPrefix}/job/${job.id}?pr=${{ needs.context.outputs.pr_id }}`; + await github.rest.repos.createCommitStatus({ + owner, repo, + sha: '${{ needs.context.outputs.pr_sha }}', + state: 'success', + context: 'PR Gate / ${{ needs.context.outputs.gate_status_name }}', + description: 'Gate Passed!', + target_url: targetUrl + }); + - name: Check if exists + uses: actions/github-script@v7 + id: check_status + with: + script: | + const { owner, repo } = context.repo; + const { data: statuses } = await github.rest.repos.listCommitStatusesForRef({ + owner, repo, ref: '${{ needs.context.outputs.pr_sha }}' + }); + const status = statuses.find(s => s.context === process.env.EDEN_IN_PR_STATUS_TITLE); + core.setOutput('exists', status ? 'true' : 'false'); + - name: Create entity in check list + if: ${{ steps.check_status.outputs.exists == 'false' }} + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + await github.rest.repos.createCommitStatus({ + owner, repo, + sha: '${{ needs.context.outputs.pr_sha }}', + state: 'pending', + context: process.env.EDEN_IN_PR_STATUS_TITLE, + description: 'Tests running ...', + target_url: process.env.EDEN_IN_PR_DETAILS_URL + }); + + tests: + name: ${{ needs.context.outputs.eden_parent_job_title }} + needs: context + if: needs.context.outputs.skip_run == 'false' + uses: lf-edge/eden/.github/workflows/test.yml@1.0.4 + secrets: inherit + with: + eve_image: "evebuild/pr:${{ needs.context.outputs.pr_id }}" + eve_log_level: "debug" + eve_artifact_name: "eve-${{ needs.context.outputs.hv }}-${{ needs.context.outputs.arch }}-${{ needs.context.outputs.platform }}" + artifact_run_id: ${{ needs.context.outputs.original_run_id }} + eden_version: "1.0.4" + + finalize: + if: always() && needs.context.outputs.skip_run == 'false' + needs: [context, tests] + runs-on: ubuntu-latest + steps: + - name: Update commit status + uses: actions/github-script@v7 + env: + SHA: ${{ needs.context.outputs.pr_sha }} + RESULT: ${{ needs.tests.result }} # success / failure / cancelled + EDEN_IN_PR_STATUS_TITLE: ${{ needs.context.outputs.eden_in_pr_status_title }} + with: + script: | + const { owner, repo } = context.repo; + const map = { success:'success', failure:'failure', cancelled:'error' }; + const state = map[process.env.RESULT] || 'error'; + await github.rest.repos.createCommitStatus({ + owner, repo, + sha: process.env.SHA, + state, + context: process.env.EDEN_IN_PR_STATUS_TITLE, + description: `Eden tests ${state}`, + target_url: `${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}` + }); + + subjob_statuses: + if: always() && needs.context.outputs.skip_run == 'false' + needs: [context, tests] # ensure Eden has finished + runs-on: ubuntu-latest + steps: + - name: Surface each Eden job as PR status + uses: actions/github-script@v7 + env: + SHA: ${{ needs.context.outputs.pr_sha }} + EDEN_PARENT_JOB_TITLE: ${{ needs.context.outputs.eden_parent_job_title }} + with: + script: | + const { owner, repo } = context.repo; + const runId = process.env.GITHUB_RUN_ID; + const parentName = process.env.EDEN_PARENT_JOB_TITLE; + const prefix = `${parentName} / `; + + const { data } = await github.rest.actions.listJobsForWorkflowRun({ + owner, repo, run_id: runId, per_page: 100 + }); + + for (const job of data.jobs) { + // child jobs only + if (!job.name.startsWith(prefix)) continue; + + // Filter out "Determine best available runner" postfix jobs + if (job.name.includes('Determine best available runner')) continue; + + const state = job.conclusion === 'success' + ? 'success' + : job.conclusion === 'failure' ? 'failure' + : 'error'; + + await github.rest.repos.createCommitStatus({ + owner, repo, + sha: process.env.SHA, + context: `${job.name}`, + state: state, + target_url: job.html_url, + description:`${state}` + }); + } diff --git a/.github/workflows/eden.yml b/.github/workflows/eden.yml deleted file mode 100644 index fdbdca09002..00000000000 --- a/.github/workflows/eden.yml +++ /dev/null @@ -1,139 +0,0 @@ ---- -name: lf-edge/eden Test suite - -# yamllint disable-line rule:truthy -on: - push: - branches: - - "master" - - "[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+-stable" - paths-ignore: - - '**/*.md' - - '.github/**' - pull_request_review: - types: [submitted] - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: true - -jobs: - check_md_wf_files: - if: ${{ github.event.review.state == 'approved' }} - runs-on: ubuntu-latest - outputs: - only_md: ${{ steps.check_md_wf.outputs.only_md }} - workflow_files_modified: ${{ steps.check_md_wf.outputs.workflow_files_modified }} - steps: - - name: Check if only Markdown files are changed - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - # Get the list of modified files - PR_API_URL="${{ github.event.pull_request._links.self.href }}/files" - RESPONSE=$(curl -s -H "Authorization: token $GITHUB_TOKEN" $PR_API_URL) - # Check if the response is valid JSON - echo "$RESPONSE" | jq . > /dev/null || exit 1 - MODIFIED_FILES=$(echo "$RESPONSE" | jq -r '.[].filename') - NON_MD_FILES=$(echo "$MODIFIED_FILES" | grep -v '\.md$' || true) - if [ -z "$NON_MD_FILES" ]; then - echo "All files are Markdown (.md) files." - echo "only_md=true" >> $GITHUB_ENV - else - echo "Non-Markdown files detected." - echo "only_md=false" >> $GITHUB_ENV - fi - # Check if any GitHub workflow files in .github/workflows are modified - if echo "$MODIFIED_FILES" | grep -qE '^\.github/workflows/.*\.(yml|yaml)$'; then - echo "GitHub workflow files in .github/workflows are modified." - echo "workflow_files_modified=true" >> $GITHUB_ENV - else - echo "No GitHub workflow files in .github/workflows are modified." - echo "workflow_files_modified=false" >> $GITHUB_ENV - fi - - name: Output result - id: check_md_wf - run: | - echo "${{ env.only_md }}" - echo "only_md=${{ env.only_md }}" >> $GITHUB_OUTPUT - echo "${{ env.workflow_files_modified }}" - echo "workflow_files_modified=${{ env.workflow_files_modified }}" >> $GITHUB_OUTPUT - - get_run_id: - if: ${{ github.event.review.state == 'approved' && needs.check_md_wf_files.outputs.only_md == 'false' && needs.check_md_wf_files.outputs.workflow_files_modified == 'false' }} - runs-on: ubuntu-latest - needs: check_md_wf_files - outputs: - run_id: ${{ steps.get_run_id.outputs.run_id }} - steps: - - name: Get the GitHub context - run: echo "github context is $GITHUB_CONTEXT" - env: - GITHUB_CONTEXT: ${{ toJson(github) }} - - name: Print the head SHA - run: | - echo ${{ github.event.pull_request.head.sha }} - - name: Print pull request url - run: | - echo ${{ github.event.pull_request._links.self.href }} - - name: Get run ID for the artifact upload - id: get_run_id - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RESPONSE=$(curl -s -L "https://api.github.com/repos/${{ github.repository }}/actions/workflows/build.yml/runs?head_sha=${{ github.event.pull_request.head.sha }}&status=success" \ - -H "Authorization: Bearer $GITHUB_TOKEN" \ - -H "Accept: application/vnd.github+json") - # Check if RESPONSE is empty - if [ -z "$RESPONSE" ]; then - echo "API call returned no response." - exit 1 - fi - RUN_ID=$(echo $RESPONSE | jq -r '.workflow_runs[0].id') - # Check if the RUN_ID is empty - if [ "$RUN_ID" == "null" ] || [ -z "$RUN_ID" ]; then - echo "No successful runs found" - exit 1 - fi - echo $RUN_ID - echo "run_id=$RUN_ID" >> $GITHUB_OUTPUT - - name: Report Run ID # useful for debugging - run: echo "Run ID is ${{ steps.get_run_id.outputs.run_id }}" - - test_suite_pr: - needs: get_run_id - strategy: - fail-fast: false - # we do not really need a matrix, as we only do amd64/kvm, - # but this makes it more consistent with the build, and will - # let us expand in the future, if we want - matrix: - arch: [amd64] - hv: [kvm] - platform: ["generic"] - if: github.event.review.state == 'approved' - uses: lf-edge/eden/.github/workflows/test.yml@1.0.2 - secrets: inherit - with: - eve_image: "evebuild/pr:${{ github.event.pull_request.number }}" - eve_log_level: "debug" - eve_artifact_name: eve-${{ matrix.hv }}-${{ matrix.arch }}-${{ matrix.platform }} - artifact_run_id: ${{ needs.get_run_id.outputs.run_id }} - eden_version: "1.0.2" - - test_suite_master: - if: github.ref == 'refs/heads/master' - uses: lf-edge/eden/.github/workflows/test.yml@1.0.2 - secrets: inherit - with: - eve_image: "lfedge/eve:snapshot" - eden_version: "1.0.2" - - test_suite_tag: - if: startsWith(github.ref, 'refs/tags') - uses: lf-edge/eden/.github/workflows/test.yml@1.0.2 - secrets: inherit - with: - eve_image: "lfedge/eve:${{ github.ref_name }}" - eden_version: "1.0.2" diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000000..f11a6b40e71 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,147 @@ +# Copyright (c) 2025, Zededa, Inc. +# SPDX-License-Identifier: Apache-2.0 +--- +name: PR Gate + +on: # yamllint disable-line rule:truthy + workflow_run: + workflows: ["PR build"] + types: [completed] + + pull_request_review: + types: [submitted] + +env: + BUILD_WF_NAME: PR build + RUN_CONTEXT_FILE: run-context.json + +# one gate run per PR; new workflow_run cancels the older run, reviews do not +concurrency: + group: | + ${{ github.event.workflow_run.pull_requests[0].number || format('review-{0}-{1}', github.event.pull_request.number, github.run_id) }} + cancel-in-progress: ${{ github.event_name == 'workflow_run' }} + +jobs: + eden-gate: + name: Ready for Eden + strategy: + fail-fast: false + matrix: + hv: [kvm] + arch: [amd64] + platform: [generic] + if: > + (github.event_name == 'workflow_run') || + (github.event_name == 'pull_request_review' && github.event.review.state == 'approved') + + runs-on: ubuntu-latest + steps: + + - name: Gather Context (workflow_run) + id: from_run + if: github.event_name == 'workflow_run' + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pr_id=$(gh api /search/issues -X GET -f q="type:pr is:open repo:${GH_REPO} sha:${{ github.event.workflow_run.head_sha }}" -q '.items[0].number') + echo "pr_id=$pr_id" >> "$GITHUB_OUTPUT" + echo "pr_sha=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT" + echo "original_run_id=${{ github.event.workflow_run.id }}" >> "$GITHUB_OUTPUT" + + - name: Gather Context (pull_request_review) + id: from_review + if: github.event_name == 'pull_request_review' + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pr_sha="${{ github.event.pull_request.head.sha }}" + original_run_id=$(gh api /repos/${GH_REPO}/actions/runs \ + -q ".workflow_runs[] | select(.head_sha==\"$pr_sha\" and .name==\"${{ env.BUILD_WF_NAME }}\") | .id" | head -n1) + echo "pr_id=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + echo "pr_sha=$pr_sha" >> "$GITHUB_OUTPUT" + echo "original_run_id=$original_run_id" >> "$GITHUB_OUTPUT" + + - name: Promote Context for Local Use + id: meta + run: | + echo "pr_id=${{ steps.from_run.outputs.pr_id || steps.from_review.outputs.pr_id }}" >> "$GITHUB_OUTPUT" + echo "pr_sha=${{ steps.from_run.outputs.pr_sha || steps.from_review.outputs.pr_sha }}" >> "$GITHUB_OUTPUT" + echo "original_run_id=${{ steps.from_run.outputs.original_run_id || steps.from_review.outputs.original_run_id }}" >> "$GITHUB_OUTPUT" + + - name: Check Review Decision + id: reviews + env: + GH_TOKEN: ${{ github.token }} + PR: ${{ steps.meta.outputs.pr_id }} + GH_REPO: ${{ github.repository }} + run: | + decision=$(gh pr view "$PR" --json reviewDecision -q .reviewDecision) + echo "approved=$([[ $decision == 'APPROVED' ]] && echo true || echo false)" >> $GITHUB_OUTPUT + + - name: Check Build Result + id: build + env: + GH_TOKEN: ${{ github.token }} + RUN_ID: ${{ steps.meta.outputs.original_run_id }} + GH_REPO: ${{ github.repository }} + BUILD_JOB_NAME: "eve (${{ matrix.arch }}, ${{ matrix.hv }}, ${{ matrix.platform }})" + run: | + build_conclusion=$(gh api \ + /repos/$GH_REPO/actions/runs/$RUN_ID/jobs \ + -X GET \ + -q ".jobs[] | select(.name == \"$BUILD_JOB_NAME\") | .conclusion" ) || api_status=$? + + if [[ "${api_status:-0}" -ne 0 ]]; then + echo "::error::Failed to fetch build job status for run $RUN_ID" + echo "build_ok=false" >>"$GITHUB_OUTPUT" + else + echo "build_ok=$([[ "$build_conclusion" == 'success' ]] && echo true || echo false)" >>"$GITHUB_OUTPUT" + fi + + - name: Check Gate Condition + id: check + run: | + echo "Approved: ${{ steps.reviews.outputs.approved }}" + echo "Build OK: ${{ steps.build.outputs.build_ok }}" + if [[ "${{ steps.reviews.outputs.approved }}" != "true" || + "${{ steps.build.outputs.build_ok }}" != "true" ]]; then + echo "gate_passed=false" >> "$GITHUB_OUTPUT" + echo "::error::Gate not satisfied: PR build: ${{ steps.build.outputs.build_ok }}, PR approved: ${{ steps.reviews.outputs.approved }}" + else + echo "gate_passed=true" >> "$GITHUB_OUTPUT" + fi + + + - name: Gather Context for Trusted Workflow + if: ${{ steps.check.outputs.gate_passed == 'true' }} + id: create_gate_context + run: | + echo "Build passed and PR approved – gate satisfied" + # Create gate context file + # This file will be used by the eden-trusted workflow to run tests + # It should contain the PR number, original WF run ID, and SHA of the commit + echo '{ "pr_id": "${{ steps.meta.outputs.pr_id }}", + "original_run_id": "${{ steps.meta.outputs.original_run_id }}", + "pr_sha": "${{ steps.meta.outputs.pr_sha }}", + "hv": "${{ matrix.hv }}", + "arch": "${{ matrix.arch }}", + "platform": "${{ matrix.platform }}", + "gate_run_id": "${{ github.run_id }}", + "gate_status_name": "Ready for Eden (${{ matrix.hv }}, ${{ matrix.arch }}, ${{ matrix.platform }})" + }' \ + > "${{ env.RUN_CONTEXT_FILE }}" + + - name: Gather Failure Context for Trusted Workflow + if: ${{ steps.check.outputs.gate_passed != 'true' }} + id: create_failure_context + run: | + echo "Gate not satisfied, creating failure context" + echo "exit" > "${{ env.RUN_CONTEXT_FILE }}" + + - name: Upload Context for Trusted Workflow + uses: actions/upload-artifact@v4 + with: + name: run-context + path: ${{ env.RUN_CONTEXT_FILE }}