diff --git a/.github/actions/validate-actor/action.yaml b/.github/actions/validate-actor/action.yaml new file mode 100644 index 00000000..dda8ea34 --- /dev/null +++ b/.github/actions/validate-actor/action.yaml @@ -0,0 +1,38 @@ +name: Validate Access +description: 'Check if the workflow is triggered by an admin user' + +# This callable workflow checks if the workflow is triggered by +# a code owner or an image maintainer by testing the github.actor +# variable against the CODEOWNERS file and the contacts.yaml file +# under oci/* path + +inputs: + admin-only: + description: 'The protected workflow should only be triggered as a code owner or an image maintainer' + required: true + default: 'false' + image-path: + description: 'The path to the image to be built' + required: true + github-token: + description: 'The GITHUB_TOKEN for the GitHub CLI' + required: true + +runs: + using: "composite" + steps: + - name: Check if the workflow is triggered by an admin user + id: check-if-permitted + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + run: ./.github/actions/validate-actor/validate-actor.sh ${{ github.actor }} ${{ inputs.admin-only }} ${{ github.workspace }} ${{ inputs.image-path }} + + - name: Cancel the remaining workflow if the actor is not permitted + if: ${{ !cancelled() && steps.check-if-permitted.outcome == 'failure' }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + echo "The workflow is not triggered by a permitted user. Cancelling the workflow." + gh run cancel ${{ github.run_id }} diff --git a/.github/actions/validate-actor/test-validate-actor.bats b/.github/actions/validate-actor/test-validate-actor.bats new file mode 100755 index 00000000..d7595609 --- /dev/null +++ b/.github/actions/validate-actor/test-validate-actor.bats @@ -0,0 +1,46 @@ +#!/usr/bin/env bats + +SOURCE_DIR=$(dirname -- "${BASH_SOURCE[0]}") + +setup() { + workdir=$(mktemp -d) + mkdir -p $workdir/img + echo -n "* @code-owner" > $workdir/CODEOWNERS + echo -e "maintainers:\n - maintainer" > $workdir/img/contacts.yaml +} + +@test "blocks non-code-owner-non-maintainer user" { + { + output=$(${BATS_TEST_DIRNAME}/validate-actor.sh "random" "true" $workdir "img" 2>&1) + exit_status=$? + } || true + [[ $exit_status -eq 1 ]] + [[ $(echo "${output}"| tail -n 1) = "The workflow is triggered by a user neither as a code owner nor a maintainer of the image img" ]] +} + +@test "allows code owner" { + output=$(${BATS_TEST_DIRNAME}/validate-actor.sh "code-owner" "true" $workdir "img" 2>&1) + [[ $(echo "${output}"| tail -n 1) = "The workflow is triggered by code-owner as the code owner" ]] +} + +@test "allows image maintainer" { + output=$(${BATS_TEST_DIRNAME}/validate-actor.sh "maintainer" "true" $workdir "img") + [[ $(echo "${output}"| tail -n 1) = "The workflow is triggered by maintainer as a maintainer of the image img" ]] +} + +@test "allows non-code-owner-non-maintainer user" { + output=$(${BATS_TEST_DIRNAME}/validate-actor.sh "random" "false" $workdir "img") + [[ $(echo "${output}"| tail -n 1) = "The workflow is not restricted to non-code-owner or non-maintainer users" ]] +} + +@test "user as both code-owner and maintainer is triggered as code owner" { + echo -n " @maintainer" >> $workdir/CODEOWNERS + output=$(${BATS_TEST_DIRNAME}/validate-actor.sh "maintainer" "true" $workdir "img") + [[ $(echo "${output}"| tail -n 1) = "The workflow is triggered by maintainer as the code owner" ]] +} + +@test "teams are expanded to team members" { + echo -n "@canonical/rocks" >> $workdir/CODEOWNERS + output=$(${BATS_TEST_DIRNAME}/validate-actor.sh "ROCKsBot" "true" $workdir "img") + [[ $(echo "${output}"| tail -n 1) = "The workflow is triggered by ROCKsBot as the code owner" ]] +} diff --git a/.github/actions/validate-actor/validate-actor.sh b/.github/actions/validate-actor/validate-actor.sh new file mode 100755 index 00000000..da2401b4 --- /dev/null +++ b/.github/actions/validate-actor/validate-actor.sh @@ -0,0 +1,36 @@ +#!/bin/bash -e + +actor=$1 +admin_only=$2 +workspace=$3 +image_path=$4 + +echo "github.actor: ${actor}" +echo "admin-only: ${admin_only}" +if [[ ${admin_only} == true ]]; then + exit_status=0 + echo "Expanding team mentions in the CODEOWNERS file" + cp ${workspace}/CODEOWNERS ${workspace}/CODEOWNERS.bak + teams=$(grep -oE '@[[:alnum:]_.-]+\/[[:alnum:]_.-]+' ${workspace}/CODEOWNERS || true | sort | uniq) + + for team in ${teams}; do + org=$(echo ${team} | cut -d'/' -f1 | sed 's/@//') + team_name=$(echo ${team} | cut -d'/' -f2) + members=$(gh api "/orgs/${org}/teams/${team_name}/members" | jq -r '.[].login') + replacement=$(echo "${members}" | xargs -I {} echo -n "@{} " | awk '{$1=$1};1') + sed -i "s|${team}|${replacement}|g" ${workspace}/CODEOWNERS + done + + if grep -wq "@${actor}" ${workspace}/CODEOWNERS; then + echo "The workflow is triggered by ${actor} as the code owner" + elif cat ${workspace}/${image_path}/contacts.yaml | yq ".maintainers" | grep "\- " | grep -wq "${actor}"; then + echo "The workflow is triggered by ${actor} as a maintainer of the image ${image_path}" + else + echo "The workflow is triggered by a user neither as a code owner nor a maintainer of the image ${image_path}" + exit_status=1 + fi + mv ${workspace}/CODEOWNERS.bak ${workspace}/CODEOWNERS + exit ${exit_status} +else + echo "The workflow is not restricted to non-code-owner or non-maintainer users" +fi diff --git a/.github/workflows/Announcements.yaml b/.github/workflows/Announcements.yaml index 6876c084..c379700a 100644 --- a/.github/workflows/Announcements.yaml +++ b/.github/workflows/Announcements.yaml @@ -33,6 +33,14 @@ jobs: fi done + - name: Validate access to triggered image + uses: ./.github/actions/validate-actor + if: ${{ github.repository == 'canonical/oci-factory' }} + with: + admin-only: true + image-path: "oci/${{ steps.get-image-name.outputs.img-name }}" + github-token: ${{ secrets.ROCKSBOT_TOKEN }} + - name: Get contacts for ${{ steps.get-image-name.outputs.img-name }} id: get-contacts working-directory: oci/${{ steps.get-image-name.outputs.img-name }} diff --git a/.github/workflows/Build-Rock.yaml b/.github/workflows/Build-Rock.yaml index 495c8650..b53d4086 100644 --- a/.github/workflows/Build-Rock.yaml +++ b/.github/workflows/Build-Rock.yaml @@ -49,7 +49,17 @@ jobs: if: ${{ steps.clone-image-repo.outcome == 'failure' }} run: | git clone ${{ inputs.rock-repo }} . + + - name: Validate access to triggered image + uses: ./.github/actions/validate-actor + if: ${{ github.repository == 'canonical/oci-factory' }} + with: + admin-only: true + image-path: ${{ inputs.oci-factory-path }} + github-token: ${{ secrets.ROCKSBOT_TOKEN }} + - run: git checkout ${{ inputs.rock-repo-commit }} + - run: sudo snap install yq --channel=v4/stable - name: Validate image naming and base working-directory: ${{ inputs.rockfile-directory }} diff --git a/.github/workflows/Documentation.yaml b/.github/workflows/Documentation.yaml index e67f1210..a1aa61ec 100644 --- a/.github/workflows/Documentation.yaml +++ b/.github/workflows/Documentation.yaml @@ -5,6 +5,8 @@ on: push: paths: - "oci/*/documentation.y*ml" + branches: + - main workflow_dispatch: inputs: oci-image-name: @@ -40,6 +42,14 @@ jobs: - uses: actions/checkout@v4 + - name: Validate access to triggered image + uses: ./.github/actions/validate-actor + if: ${{ github.repository == 'canonical/oci-factory' }} + with: + admin-only: true + image-path: "oci/${{ inputs.oci-image-name }}" + github-token: ${{ secrets.ROCKSBOT_TOKEN }} + - name: Infer images to document uses: tj-actions/changed-files@v35 id: changed-files @@ -149,7 +159,7 @@ jobs: runs-on: ubuntu-22.04 name: Notify on failure needs: [validate-documentation-request, do-documentation] - if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name != 'workflow_dispatch' }} + if: ${{ !cancelled() && contains(needs.*.result, 'failure') && github.event_name != 'workflow_dispatch' }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/Image.yaml b/.github/workflows/Image.yaml index ae393492..d0853551 100644 --- a/.github/workflows/Image.yaml +++ b/.github/workflows/Image.yaml @@ -94,6 +94,14 @@ jobs: echo "img-name=$(basename ${img_path})" >> "$GITHUB_OUTPUT" echo "img-path=${img_path}" >> "$GITHUB_OUTPUT" + - name: Validate access to triggered image + uses: ./.github/actions/validate-actor + if: ${{ github.repository == 'canonical/oci-factory' }} + with: + admin-only: true + image-path: ${{ steps.validate-image.outputs.img-path }} + github-token: ${{ secrets.ROCKSBOT_TOKEN }} + - name: Use custom image trigger if: ${{ inputs.b64-image-trigger != '' }} run: echo ${{ inputs.b64-image-trigger }} | base64 -d > ${{ steps.validate-image.outputs.img-path }}/image.yaml @@ -113,7 +121,7 @@ jobs: ./src/image/prepare_single_image_build_matrix.py \ --oci-path ${{ steps.validate-image.outputs.img-path }} \ - --revision-data-dir ${{ env.DATA_DIR }} \ + --revision-data-dir ${{ env.DATA_DIR }} run-build: needs: [prepare-build] @@ -568,7 +576,7 @@ jobs: runs-on: ubuntu-22.04 name: Notify needs: [prepare-build, run-build, upload, prepare-releases, generate-provenance] - if: ${{ always() && contains(needs.*.result, 'failure') && github.event_name != 'pull_request' }} + if: ${{ !cancelled() && contains(needs.*.result, 'failure') && github.event_name != 'pull_request' }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/Release.yaml b/.github/workflows/Release.yaml index dbc6a249..515d345d 100644 --- a/.github/workflows/Release.yaml +++ b/.github/workflows/Release.yaml @@ -39,6 +39,14 @@ jobs: - uses: actions/checkout@v4 + - name: Validate access to triggered image + uses: ./.github/actions/validate-actor + if: ${{ github.repository == 'canonical/oci-factory' }} + with: + admin-only: true + image-path: "oci/${{ inputs.oci-image-name }}" + github-token: ${{ secrets.ROCKSBOT_TOKEN }} + - name: Infer number of image triggers uses: tj-actions/changed-files@v35 id: changed-files diff --git a/.github/workflows/Tests.yaml b/.github/workflows/Tests.yaml index ecd58cec..7d02297e 100644 --- a/.github/workflows/Tests.yaml +++ b/.github/workflows/Tests.yaml @@ -66,9 +66,22 @@ env: DIVE_IMAGE: 'wagoodman/dive:v0.12' jobs: + access-check: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - name: Validate access to triggered image + uses: ./.github/actions/validate-actor + if: ${{ github.repository == 'canonical/oci-factory' }} + with: + admin-only: true + image-path: ${{ inputs.oci-image-path }} + github-token: ${{ secrets.ROCKSBOT_TOKEN }} + fetch-oci-image: runs-on: ubuntu-22.04 name: Fetch OCI image for testing + needs: [access-check] outputs: test-cache-key: ${{ steps.cache.outputs.key }} steps: diff --git a/.github/workflows/Vulnerability-Scan.yaml b/.github/workflows/Vulnerability-Scan.yaml index 39dabe90..1a055759 100644 --- a/.github/workflows/Vulnerability-Scan.yaml +++ b/.github/workflows/Vulnerability-Scan.yaml @@ -44,6 +44,14 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Validate access to triggered image + uses: ./.github/actions/validate-actor + if: ${{ github.repository == 'canonical/oci-factory' }} + with: + admin-only: true + image-path: ${{ inputs.oci-image-path }} + github-token: ${{ secrets.ROCKSBOT_TOKEN }} + - id: vulnerability-report run: | full_name="${{ inputs.oci-image-name }}${{ inputs.vulnerability-report-suffix }}" @@ -104,7 +112,7 @@ jobs: image-ref: '${{ steps.to-docker-daemon.outputs.name }}' - name: Process report - if: ${{ always() }} + if: ${{ !cancelled() }} id: check-report run: | report="${{ steps.vulnerability-report.outputs.name }}" @@ -134,13 +142,13 @@ jobs: done - uses: actions/cache/save@v4 - if: ${{ always() }} + if: ${{ !cancelled() }} with: path: ${{ steps.vulnerability-report.outputs.name }} key: ${{ github.run_id }}-${{ steps.vulnerability-report.outputs.name }} - uses: actions/upload-artifact@v4 - if: ${{ always() }} + if: ${{ !cancelled() }} with: name: ${{ steps.vulnerability-report.outputs.name }} path: ${{ steps.vulnerability-report.outputs.name }} @@ -153,7 +161,7 @@ jobs: name: Notify on failure needs: - test-vulnerabilities - if: ${{ always() && needs.test-vulnerabilities.outputs.notify == 'true' }} + if: ${{ !cancelled() && needs.test-vulnerabilities.outputs.notify == 'true' }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/_Test-OCI-Factory.yaml b/.github/workflows/_Test-OCI-Factory.yaml index e9708a04..25e1efb9 100644 --- a/.github/workflows/_Test-OCI-Factory.yaml +++ b/.github/workflows/_Test-OCI-Factory.yaml @@ -4,6 +4,7 @@ on: push: paths: - ".github/workflows/*" + - ".github/actions/**" - "!.github/workflows/CLA-Check.yaml" - "!.github/workflows/PR-Validator.yaml" - "!.github/workflows/_Auto-updates.yaml" @@ -25,6 +26,16 @@ env: jobs: + access-check: + name: Validate access to mock-rock + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/validate-actor + with: + admin-only: true + image-path: "oci/mock-rock" + github-token: ${{ secrets.ROCKSBOT_TOKEN }} pytest: # Trigger python unit tests across the repository @@ -64,8 +75,31 @@ jobs: path: ${{ env.PYTEST_RESULT_PATH }} if-no-files-found: error + bats-test: + # Trigger bash unit tests across the repository + name: bats + runs-on: ubuntu-22.04 + steps: + + # Job Setup + - uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Install bats + run: | + sudo apt-get update + sudo apt-get install -y bats + + - name: Run bats + env: + GITHUB_TOKEN: ${{ secrets.ROCKSBOT_TOKEN }} + run: | + find ${{ github.workspace }} -name 'test-*.bats' | xargs bats + test-workflows: name: Trigger internal tests for mock-rock + needs: [access-check] uses: ./.github/workflows/Image.yaml with: oci-image-name: "mock-rock" diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..0afe0e53 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1 @@ +* @canonical/rocks diff --git a/oci/mock-rock/_releases.json b/oci/mock-rock/_releases.json index 198dc4cf..252e5782 100644 --- a/oci/mock-rock/_releases.json +++ b/oci/mock-rock/_releases.json @@ -13,13 +13,13 @@ }, "1.0-22.04": { "candidate": { - "target": "478" + "target": "530" }, "beta": { - "target": "478" + "target": "530" }, "edge": { - "target": "478" + "target": "530" }, "end-of-life": "2025-05-01T00:00:00Z" }, @@ -35,31 +35,31 @@ "1.1-22.04": { "end-of-life": "2025-05-01T00:00:00Z", "candidate": { - "target": "494" + "target": "531" }, "beta": { - "target": "494" + "target": "531" }, "edge": { - "target": "494" + "target": "531" } }, "1-22.04": { "end-of-life": "2025-05-01T00:00:00Z", "candidate": { - "target": "479" + "target": "531" }, "beta": { - "target": "479" + "target": "531" }, "edge": { - "target": "479" + "target": "531" } }, "1.2-22.04": { "end-of-life": "2025-05-01T00:00:00Z", "beta": { - "target": "480" + "target": "532" }, "edge": { "target": "1.2-22.04_beta"