From 9ceafbc856c7ea090df21042e49c54abec9b944f Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 16:13:31 -0500 Subject: [PATCH 01/10] ci: isolate pull request build jobs Keep pull_request_target workflow definitions trusted while forcing PR code onto GitHub-hosted runners with read-only permissions. Reuse default-branch containers for PRs and reserve package and cache writes for trusted pushes. Co-Authored-By: Claude --- .github/workflows/build-container.yml | 21 +++-- .github/workflows/build-depends.yml | 27 +++++-- .github/workflows/build-src.yml | 9 ++- .github/workflows/build.yml | 80 ++++++++++++++++--- .github/workflows/cache-depends-sources.yml | 18 ++++- .github/workflows/lint.yml | 9 ++- .github/workflows/select_dynamic_runner.py | 28 ++++--- .github/workflows/test-src.yml | 27 +++++-- .../workflows/test_select_dynamic_runner.py | 41 ++++------ 9 files changed, 181 insertions(+), 79 deletions(-) diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index 078a3e84a5ec..f78f7be88456 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -31,7 +31,11 @@ on: jobs: build-amd64: name: Build container (amd64) + if: github.event_name == 'push' runs-on: ${{ inputs.runs-on-amd64 }} + permissions: + contents: read + packages: write outputs: tag: ${{ steps.prepare.outputs.tag }} repo: ${{ steps.prepare.outputs.repo }} @@ -40,8 +44,7 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.sha }} persist-credentials: false - name: Prepare variables @@ -79,15 +82,18 @@ jobs: build-arm64: name: Build container (arm64) + if: github.event_name == 'push' runs-on: ${{ inputs.runs-on-arm64 }} + permissions: + contents: read + packages: write outputs: digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.sha }} persist-credentials: false - name: Prepare variables @@ -125,14 +131,17 @@ jobs: create-manifest: name: Create multi-arch manifest + if: github.event_name == 'push' runs-on: ${{ inputs.runs-on-arm64 }} + permissions: + contents: read + packages: write needs: [build-amd64, build-arm64] steps: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.sha }} persist-credentials: false - name: Set up Docker Buildx diff --git a/.github/workflows/build-depends.yml b/.github/workflows/build-depends.yml index 07b89e270203..f06e46cde7ce 100644 --- a/.github/workflows/build-depends.yml +++ b/.github/workflows/build-depends.yml @@ -40,6 +40,9 @@ jobs: check-cache: name: Check cache runs-on: ${{ inputs.runs-on }} + permissions: + actions: read + contents: read outputs: cache-hit: ${{ steps.cache-check.outputs.cache-hit }} cache-key: ${{ steps.setup.outputs.cache-key }} @@ -51,9 +54,9 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} sparse-checkout: | ci/dash ci/test @@ -85,7 +88,7 @@ jobs: - name: Check for cached depends id: cache-check - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: depends/built/${{ steps.setup.outputs.HOST }} key: ${{ steps.setup.outputs.cache-key }} @@ -93,7 +96,7 @@ jobs: - name: Cache SDKs id: cache-sdk-check - uses: actions/cache@v5 + uses: actions/cache/restore@v5 if: inputs.build-target == 'mac' with: path: depends/SDKs @@ -109,6 +112,13 @@ jobs: ./contrib/containers/guix/scripts/setup-sdk echo "artifact=depends-sdks-${BUILD_TARGET}" >> "${GITHUB_OUTPUT}" + - name: Save SDKs cache + if: github.event_name == 'push' && steps.prepare-sdks.outputs.artifact != '' + uses: actions/cache/save@v5 + with: + path: depends/SDKs + key: depends-sdks-${{ hashFiles('depends/hosts/darwin.mk') }} + - name: Upload prepared SDKs # This originates in check-cache so both Build depends (when needed) # and Build source can use it even when a PR cannot save the cache. @@ -126,6 +136,9 @@ jobs: needs: [check-cache] if: needs.check-cache.outputs.cache-hit != 'true' runs-on: ${{ inputs.runs-on }} + permissions: + actions: read + contents: read outputs: built-artifact: depends-built-${{ inputs.build-target }} container: @@ -135,9 +148,9 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Restore depends sources uses: actions/cache/restore@v5 @@ -186,7 +199,7 @@ jobs: # Cache tokens on untrusted triggers are read-only (GitHub change, # June 2026), so only trusted events can save the persistent cache # for later read-only restores. - if: github.event_name != 'pull_request_target' + if: github.event_name == 'push' uses: actions/cache/save@v5 with: path: depends/built/${{ needs.check-cache.outputs.host }} diff --git a/.github/workflows/build-src.yml b/.github/workflows/build-src.yml index 8ea9ba443378..d8bfb87312e7 100644 --- a/.github/workflows/build-src.yml +++ b/.github/workflows/build-src.yml @@ -47,6 +47,9 @@ jobs: build-src: name: Build source runs-on: ${{ inputs.runs-on }} + permissions: + actions: read + contents: read outputs: key: ${{ steps.bundle.outputs.key }} container: @@ -56,10 +59,10 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true - persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 50 + persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Initial setup id: setup diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e8c993c6eeec..e46c64ab63d4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,6 @@ on: permissions: actions: read contents: read - packages: write concurrency: group: | @@ -21,8 +20,9 @@ concurrency: jobs: check-skip: name: Check skip conditions - # Keep the bootstrap job cheap; RUNNER_CHECK_SKIP can point to a small Blacksmith ARM runner. - runs-on: ${{ vars.RUNNER_CHECK_SKIP || 'ubuntu-24.04-arm' }} + # Untrusted PRs must stay on ephemeral GitHub-hosted runners. Trusted pushes + # may use the configured bootstrap runner. + runs-on: ${{ github.event_name == 'pull_request_target' && 'ubuntu-24.04-arm' || vars.RUNNER_CHECK_SKIP || 'ubuntu-24.04-arm' }} outputs: skip: ${{ steps.skip-check.outputs.skip }} runner-amd64: ${{ steps.select-runner.outputs.runner_amd64 }} @@ -49,7 +49,9 @@ jobs: - name: Checkout code if: ${{ steps.skip-check.outputs.skip == 'false' }} - uses: actions/checkout@v6 + uses: actions/checkout@v4 + with: + persist-credentials: false - name: Select runners id: select-runner @@ -91,10 +93,13 @@ jobs: with: runs-on: ${{ needs.check-skip.outputs['runner-arm64'] }} - container: - name: Build container + container-publish: + name: Publish container needs: [check-skip] - if: ${{ needs.check-skip.outputs.skip == 'false' }} + if: ${{ github.event_name == 'push' && needs.check-skip.outputs.skip == 'false' }} + permissions: + contents: read + packages: write uses: ./.github/workflows/build-container.yml with: context: ./contrib/containers/ci @@ -103,10 +108,39 @@ jobs: runs-on-amd64: ${{ needs.check-skip.outputs['runner-amd64'] }} runs-on-arm64: ${{ needs.check-skip.outputs['runner-arm64'] }} - container-slim: - name: Build slim container + container: + name: Select container + needs: [check-skip, container-publish] + if: | + always() && !cancelled() && + needs.check-skip.outputs.skip == 'false' && + (github.event_name == 'pull_request_target' || needs.container-publish.result == 'success') + runs-on: ubuntu-24.04-arm + permissions: {} + outputs: + path: ${{ steps.select.outputs.path }} + steps: + - name: Select trusted container + id: select + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + PUBLISHED_PATH: ${{ needs.container-publish.outputs.path }} + REPOSITORY: ${{ github.repository }} + run: | + if [[ "${EVENT_NAME}" == "pull_request_target" ]]; then + echo "path=ghcr.io/${REPOSITORY,,}/dashcore-ci-runner:${DEFAULT_BRANCH,,}" >> "${GITHUB_OUTPUT}" + else + echo "path=${PUBLISHED_PATH}" >> "${GITHUB_OUTPUT}" + fi + + container-slim-publish: + name: Publish slim container needs: [check-skip] - if: ${{ needs.check-skip.outputs.skip == 'false' }} + if: ${{ github.event_name == 'push' && needs.check-skip.outputs.skip == 'false' }} + permissions: + contents: read + packages: write uses: ./.github/workflows/build-container.yml with: context: ./contrib/containers/ci @@ -115,6 +149,32 @@ jobs: runs-on-amd64: ${{ needs.check-skip.outputs['runner-amd64'] }} runs-on-arm64: ${{ needs.check-skip.outputs['runner-arm64'] }} + container-slim: + name: Select slim container + needs: [check-skip, container-slim-publish] + if: | + always() && !cancelled() && + needs.check-skip.outputs.skip == 'false' && + (github.event_name == 'pull_request_target' || needs.container-slim-publish.result == 'success') + runs-on: ubuntu-24.04-arm + permissions: {} + outputs: + path: ${{ steps.select.outputs.path }} + steps: + - name: Select trusted slim container + id: select + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + PUBLISHED_PATH: ${{ needs.container-slim-publish.outputs.path }} + REPOSITORY: ${{ github.repository }} + run: | + if [[ "${EVENT_NAME}" == "pull_request_target" ]]; then + echo "path=ghcr.io/${REPOSITORY,,}/dashcore-ci-slim:${DEFAULT_BRANCH,,}" >> "${GITHUB_OUTPUT}" + else + echo "path=${PUBLISHED_PATH}" >> "${GITHUB_OUTPUT}" + fi + depends-aarch64-linux: name: aarch64-linux-gnu uses: ./.github/workflows/build-depends.yml diff --git a/.github/workflows/cache-depends-sources.yml b/.github/workflows/cache-depends-sources.yml index 122851d8cd72..8e3455f630d0 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -15,6 +15,9 @@ on: jobs: cache-sources: name: Cache depends sources + permissions: + actions: read + contents: read # Intentionally keep scheduled cache warming on GitHub-hosted ARM runners. # Blacksmith caches are expected to persist long enough without a warmup cron. runs-on: ${{ inputs.runs-on || 'ubuntu-24.04-arm' }} @@ -22,13 +25,13 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Check for cached sources id: cache-check - uses: actions/cache@v5 + uses: actions/cache/restore@v5 with: path: depends/sources key: depends-sources-${{ hashFiles('depends/packages/*') }} @@ -38,3 +41,12 @@ jobs: - name: Download sources if: steps.cache-check.outputs.cache-hit != 'true' run: make -C depends download + + - name: Save sources cache + if: | + steps.cache-check.outputs.cache-hit != 'true' && + (github.event_name == 'push' || github.event_name == 'schedule') + uses: actions/cache/save@v5 + with: + path: depends/sources + key: depends-sources-${{ hashFiles('depends/packages/*') }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b936e2657c78..809177d059ed 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,6 +16,9 @@ jobs: lint: name: Run linters runs-on: ${{ inputs.runs-on }} + permissions: + actions: read + contents: read container: image: ${{ inputs.container-path }} options: --user root @@ -23,10 +26,10 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true - persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 50 + persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Initial setup run: | diff --git a/.github/workflows/select_dynamic_runner.py b/.github/workflows/select_dynamic_runner.py index 03cb0184cf1d..6a5c94cdb1c7 100644 --- a/.github/workflows/select_dynamic_runner.py +++ b/.github/workflows/select_dynamic_runner.py @@ -141,13 +141,17 @@ def select_runners( fetch_json: Callable[[str], Tuple[Dict, Dict[str, str]]], repos: Sequence[str] = DEFAULT_REPOS, ) -> Dict[str, str]: - label_names = [ - label.get("name", "") - for label in event.get("pull_request", {}).get("labels", []) - ] - label_override = ( - event_name == "pull_request_target" and "blacksmith-ci" in label_names - ) + if event_name == "pull_request_target": + return { + "runner_amd64": DEFAULT_RUNNER_AMD64, + "runner_arm64": DEFAULT_RUNNER_ARM64, + "use_blacksmith": "false", + "use_blacksmith_amd64": "false", + "use_blacksmith_arm64": "false", + "backlog_count": "not-measured", + "decision_reason": "untrusted-pr:github-hosted", + "label_override": "false", + } backlog_count = "unknown" backlog_count_value = None @@ -161,9 +165,7 @@ def select_runners( decision_parts = [] - if label_override: - decision_parts.append("label:blacksmith-ci") - elif measurement_error is not None: + if measurement_error is not None: decision_parts.append("metric-unavailable") else: decision_parts.append( @@ -181,10 +183,10 @@ def select_runners( ) ) - use_blacksmith_amd64 = label_override or ( + use_blacksmith_amd64 = ( measurement_error is None and backlog_count_value > threshold ) - use_blacksmith_arm64 = label_override or ( + use_blacksmith_arm64 = ( measurement_error is None and backlog_count_value > arm64_threshold ) @@ -216,7 +218,7 @@ def select_runners( "use_blacksmith_arm64": "true" if use_blacksmith_arm64 else "false", "backlog_count": backlog_count, "decision_reason": ";".join(decision_parts), - "label_override": "true" if label_override else "false", + "label_override": "false", } diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index f27751be60a2..e4f3eefd07e9 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -28,6 +28,9 @@ jobs: test-src: name: Test source runs-on: ${{ inputs.runs-on }} + permissions: + actions: read + contents: read container: image: ${{ inputs.container-path }} options: --user root @@ -35,22 +38,22 @@ jobs: - name: Checkout code uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true - persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 1 + persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Download build artifacts uses: actions/download-artifact@v8 with: name: ${{ inputs.bundle-key }} - - name: Manage releases cache - uses: actions/cache@v5 + - name: Restore releases cache + id: releases-cache + uses: actions/cache/restore@v5 if: inputs.build-target == 'linux64' with: - path: | - releases + path: releases key: releases-${{ hashFiles('ci/test/00_setup_env_native_qt5.sh', 'test/get_previous_releases.py') }} - name: Run functional tests @@ -65,6 +68,16 @@ jobs: ./ci/dash/test_integrationtests.sh ${INTEGRATION_TESTS_ARGS} shell: bash + - name: Save releases cache + if: | + github.event_name == 'push' && + inputs.build-target == 'linux64' && + steps.releases-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: releases + key: releases-${{ hashFiles('ci/test/00_setup_env_native_qt5.sh', 'test/get_previous_releases.py') }} + - name: Bundle test logs id: bundle if: success() || (failure() && steps.test.outcome == 'failure') diff --git a/.github/workflows/test_select_dynamic_runner.py b/.github/workflows/test_select_dynamic_runner.py index 446a33d87ece..49fc4734234c 100644 --- a/.github/workflows/test_select_dynamic_runner.py +++ b/.github/workflows/test_select_dynamic_runner.py @@ -76,24 +76,28 @@ def fetch_json(url): self.assertEqual(MODULE.count_queued_jobs(fetch_json, [repo]), 1) - def test_label_override_selects_blacksmith_even_with_low_backlog(self): + def test_pull_request_target_forces_github_hosted_runners(self): + def fetch_json(_url): + self.fail("pull request runner selection must not query backlog metrics") + outputs = MODULE.select_runners( event_name="pull_request_target", event={"pull_request": {"labels": [{"name": "blacksmith-ci"}]}}, threshold=10, arm64_threshold=30, - runner_amd64_var="blacksmith-amd64", - runner_arm64_var="blacksmith-arm64", - fetch_json=lambda _url: ({"workflow_runs": []}, {}), + runner_amd64_var="self-hosted-amd64", + runner_arm64_var="self-hosted-arm64", + fetch_json=fetch_json, repos=["dashpay/dash"], ) - self.assertEqual(outputs["use_blacksmith"], "true") - self.assertEqual(outputs["use_blacksmith_amd64"], "true") - self.assertEqual(outputs["use_blacksmith_arm64"], "true") - self.assertEqual(outputs["runner_amd64"], "blacksmith-amd64") - self.assertEqual(outputs["runner_arm64"], "blacksmith-arm64") - self.assertIn("label:blacksmith-ci", outputs["decision_reason"]) + self.assertEqual(outputs["use_blacksmith"], "false") + self.assertEqual(outputs["use_blacksmith_amd64"], "false") + self.assertEqual(outputs["use_blacksmith_arm64"], "false") + self.assertEqual(outputs["runner_amd64"], MODULE.DEFAULT_RUNNER_AMD64) + self.assertEqual(outputs["runner_arm64"], MODULE.DEFAULT_RUNNER_ARM64) + self.assertEqual(outputs["backlog_count"], "not-measured") + self.assertEqual(outputs["decision_reason"], "untrusted-pr:github-hosted") def test_backlog_threshold_selects_blacksmith_amd64_only(self): repo = "dashpay/dash" @@ -194,22 +198,5 @@ def fetch_json(_url): self.assertEqual(outputs["backlog_count"], "unknown") self.assertIn("metric-unavailable", outputs["decision_reason"]) - def test_missing_runner_vars_fall_back_per_arch(self): - outputs = MODULE.select_runners( - event_name="pull_request_target", - event={"pull_request": {"labels": [{"name": "blacksmith-ci"}]}}, - threshold=10, - arm64_threshold=30, - runner_amd64_var="", - runner_arm64_var="blacksmith-arm64", - fetch_json=lambda _url: ({"workflow_runs": []}, {}), - repos=["dashpay/dash"], - ) - - self.assertEqual(outputs["runner_amd64"], MODULE.DEFAULT_RUNNER_AMD64) - self.assertEqual(outputs["runner_arm64"], "blacksmith-arm64") - self.assertIn("amd64-github-fallback", outputs["decision_reason"]) - - if __name__ == "__main__": unittest.main() From 878e0596c360ba6883621fbf65ea215ac23827ef Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 16:13:53 -0500 Subject: [PATCH 02/10] ci: separate untrusted Guix builds Build labeled pull requests with the trusted default-branch Guix image and read-only permissions. Keep cache publication and artifact attestation in trusted push and schedule callers, and add a lint that enforces the workflow trust boundary. Co-Authored-By: Claude --- .github/workflows/guix-build-worker.yml | 118 +++++++++++++++++++ .github/workflows/guix-build.yml | 148 +++++++++--------------- test/lint/lint-ci-workflow-security.py | 109 +++++++++++++++++ 3 files changed, 279 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/guix-build-worker.yml create mode 100755 test/lint/lint-ci-workflow-security.py diff --git a/.github/workflows/guix-build-worker.yml b/.github/workflows/guix-build-worker.yml new file mode 100644 index 000000000000..2402efcce832 --- /dev/null +++ b/.github/workflows/guix-build-worker.yml @@ -0,0 +1,118 @@ +name: Guix Build Worker + +on: + workflow_call: + inputs: + source-ref: + description: "Commit to build" + required: true + type: string + container-path: + description: "Trusted Guix builder image" + required: true + type: string + trusted: + description: "Whether this run may save caches and attest artifacts" + required: true + type: boolean + +jobs: + build: + runs-on: ubuntu-24.04-arm + strategy: + matrix: + build_target: [x86_64-linux-gnu, aarch64-linux-gnu, riscv64-linux-gnu, powerpc64-linux-gnu, x86_64-w64-mingw32, x86_64-apple-darwin, arm64-apple-darwin] + + timeout-minutes: 480 + steps: + - name: Disable apparmor user namespace restrictions + run: | + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + + - name: Checkout + uses: actions/checkout@v6 + with: + ref: ${{ inputs.source-ref }} + path: dash + fetch-depth: 0 + persist-credentials: false + allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} + + - name: Restore depends sources + id: depends-sources-cache + uses: actions/cache/restore@v5 + with: + path: dash/depends/sources + key: depends-sources-${{ hashFiles('dash/depends/packages/*') }} + restore-keys: | + depends-sources- + + - name: Restore Guix and depends cache + id: guix-cache + uses: actions/cache/restore@v5 + with: + path: | + ${{ github.workspace }}/.cache + ${{ github.workspace }}/dash/depends/built + ${{ github.workspace }}/dash/depends/work + /gnu/store + key: ${{ runner.os }}-guix-${{ matrix.build_target }}-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-guix-${{ matrix.build_target }} + ${{ runner.os }}-guix- + + - name: Create .cache folder if missing + if: steps.guix-cache.outputs.cache-hit != 'true' + run: mkdir -p .cache + + - name: Run Guix build + timeout-minutes: 480 + env: + BUILD_TARGET: ${{ matrix.build_target }} + CONTAINER_PATH: ${{ inputs.container-path }} + run: | + docker run --privileged -d --rm -t \ + --name guix-daemon \ + -v "${GITHUB_WORKSPACE}/dash:/src/dash" \ + -v "${GITHUB_WORKSPACE}/.cache:/home/ubuntu/.cache" \ + -w /src/dash \ + "${CONTAINER_PATH}" + docker exec guix-daemon bash -c "HOSTS=${BUILD_TARGET} /usr/local/bin/guix-start /src/dash" + + - name: Save depends sources cache + if: inputs.trusted && steps.depends-sources-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: dash/depends/sources + key: depends-sources-${{ hashFiles('dash/depends/packages/*') }} + + - name: Save Guix and depends cache + if: inputs.trusted && steps.guix-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v5 + with: + path: | + ${{ github.workspace }}/.cache + ${{ github.workspace }}/dash/depends/built + ${{ github.workspace }}/dash/depends/work + /gnu/store + key: ${{ runner.os }}-guix-${{ matrix.build_target }}-${{ github.sha }} + + - name: Compute SHA256 checksums + continue-on-error: true # It will complain on depending on only some hosts + env: + BUILD_TARGET: ${{ matrix.build_target }} + run: | + HOSTS="${BUILD_TARGET}" ./dash/contrib/containers/guix/scripts/guix-check "${GITHUB_WORKSPACE}/dash" + + - name: Upload build artifacts + uses: actions/upload-artifact@v6 + with: + name: guix-artifacts-${{ matrix.build_target }} + path: | + ${{ github.workspace }}/dash/guix-build*/output/${{ matrix.build_target }}/ + + - name: Attest build provenance + if: inputs.trusted + uses: actions/attest@v4 + with: + subject-path: ${{ github.workspace }}/dash/guix-build*/output/${{ matrix.build_target }}/* diff --git a/.github/workflows/guix-build.yml b/.github/workflows/guix-build.yml index 98ecc74afe9e..e540b2f706a9 100644 --- a/.github/workflows/guix-build.yml +++ b/.github/workflows/guix-build.yml @@ -1,9 +1,7 @@ name: Guix Build permissions: - packages: write - id-token: write - attestations: write + contents: read on: pull_request_target: @@ -18,34 +16,35 @@ jobs: runs-on: ubuntu-24.04-arm if: | (github.event_name == 'push' && (startsWith(github.ref, 'refs/tags/') || vars.RUN_GUIX_ON_ALL_PUSH == 'true')) || - contains(github.event.pull_request.labels.*.name, 'guix-build') || github.event_name == 'schedule' + permissions: + contents: read + packages: write outputs: - image-tag: ${{ steps.prepare.outputs.image-tag }} - repo-name: ${{ steps.prepare.outputs.repo-name }} + path: ghcr.io/${{ steps.prepare.outputs.repo-name }}/dashcore-guix-builder:${{ steps.prepare.outputs.image-tag }} steps: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true - persist-credentials: false + ref: ${{ github.sha }} path: dash fetch-depth: 0 + persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Commit variables id: prepare + env: + REPOSITORY: ${{ github.repository }} run: | - echo "hash=$(sha256sum ./dash/contrib/containers/guix/Dockerfile | cut -d ' ' -f1)" >> $GITHUB_OUTPUT - echo "host_user_id=$(id -u)" >> $GITHUB_OUTPUT - echo "host_group_id=$(id -g)" >> $GITHUB_OUTPUT - BRANCH_NAME=$(echo "${GITHUB_REF##*/}" | tr '[:upper:]' '[:lower:]') - REPO_NAME=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') - echo "image-tag=${BRANCH_NAME}" >> $GITHUB_OUTPUT - echo "repo-name=${REPO_NAME}" >> $GITHUB_OUTPUT + echo "host_user_id=$(id -u)" >> "${GITHUB_OUTPUT}" + echo "host_group_id=$(id -g)" >> "${GITHUB_OUTPUT}" + BRANCH_NAME="$(echo "${GITHUB_REF##*/}" | tr '[:upper:]' '[:lower:]')" + REPO_NAME="$(echo "${REPOSITORY}" | tr '[:upper:]' '[:lower:]')" + echo "image-tag=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + echo "repo-name=${REPO_NAME}" >> "${GITHUB_OUTPUT}" - name: Login to GitHub Container Registry uses: docker/login-action@v4 @@ -71,86 +70,43 @@ jobs: cache-from: type=registry,ref=ghcr.io/${{ steps.prepare.outputs.repo-name }}/dashcore-guix-builder:latest cache-to: type=inline,mode=max - build: - needs: build-image - # runs-on: [ "self-hosted", "linux", "x64", "ubuntu-core" ] + resolve-pr-image: + if: | + github.event_name == 'pull_request_target' && + contains(github.event.pull_request.labels.*.name, 'guix-build') runs-on: ubuntu-24.04-arm - strategy: - matrix: - build_target: [x86_64-linux-gnu, aarch64-linux-gnu, riscv64-linux-gnu, powerpc64-linux-gnu, x86_64-w64-mingw32, x86_64-apple-darwin, arm64-apple-darwin] - - timeout-minutes: 480 + permissions: {} + outputs: + path: ${{ steps.resolve.outputs.path }} steps: - - name: Disable apparmor user namespace restrictions + - name: Select trusted Guix builder + id: resolve + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REPOSITORY: ${{ github.repository }} run: | - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - - name: Checkout - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true - persist-credentials: false - path: dash - fetch-depth: 0 - - - name: Cache depends sources - uses: actions/cache@v5 - with: - path: dash/depends/sources - key: depends-sources-${{ hashFiles('dash/depends/packages/*') }} - restore-keys: | - depends-sources- - - - name: Cache Guix and depends - id: guix-cache-restore - uses: actions/cache@v5 - with: - path: | - ${{ github.workspace }}/.cache - ${{ github.workspace }}/dash/depends/built - ${{ github.workspace }}/dash/depends/work - /gnu/store - key: ${{ runner.os }}-guix-${{ matrix.build_target }}-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-guix-${{ matrix.build_target }} - ${{ runner.os }}-guix- - - - name: Create .cache folder if missing - if: steps.guix-cache-restore.outputs.cache-hit != 'true' - run: mkdir -p .cache - - - name: Run Guix build - timeout-minutes: 480 - run: | - docker run --privileged -d --rm -t \ - --name guix-daemon \ - -v ${{ github.workspace }}/dash:/src/dash \ - -v ${{ github.workspace }}/.cache:/home/ubuntu/.cache \ - -w /src/dash \ - ghcr.io/${{ needs.build-image.outputs.repo-name }}/dashcore-guix-builder:${{ needs.build-image.outputs.image-tag }} && \ - docker exec guix-daemon bash -c 'HOSTS=${{ matrix.build_target }} /usr/local/bin/guix-start /src/dash' - - - name: Ensure build passes - run: | - if [[ $? != 0 ]]; then - echo "Guix build failed!" - exit 1 - fi - - - name: Compute SHA256 checksums - continue-on-error: true # It will complain on depending on only some hosts - run: | - HOSTS=${{ matrix.build_target }} ./dash/contrib/containers/guix/scripts/guix-check ${{ github.workspace }}/dash - - - name: Upload build artifacts - uses: actions/upload-artifact@v6 - with: - name: guix-artifacts-${{ matrix.build_target }} - path: | - ${{ github.workspace }}/dash/guix-build*/output/${{ matrix.build_target }}/ - - - name: Attest build provenance - uses: actions/attest@v4 - with: - subject-path: ${{ github.workspace }}/dash/guix-build*/output/${{ matrix.build_target }}/* + echo "path=ghcr.io/${REPOSITORY,,}/dashcore-guix-builder:${DEFAULT_BRANCH,,}" >> "${GITHUB_OUTPUT}" + + build-pr: + needs: resolve-pr-image + permissions: + actions: read + contents: read + uses: ./.github/workflows/guix-build-worker.yml + with: + source-ref: ${{ github.event.pull_request.head.sha }} + container-path: ${{ needs.resolve-pr-image.outputs.path }} + trusted: false + + build-trusted: + needs: build-image + permissions: + actions: read + contents: read + id-token: write + attestations: write + uses: ./.github/workflows/guix-build-worker.yml + with: + source-ref: ${{ github.sha }} + container-path: ${{ needs.build-image.outputs.path }} + trusted: true diff --git a/test/lint/lint-ci-workflow-security.py b/test/lint/lint-ci-workflow-security.py new file mode 100755 index 000000000000..726c68a3e78d --- /dev/null +++ b/test/lint/lint-ci-workflow-security.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Enforce the trust boundary for workflows that execute pull request code.""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW_DIR = ROOT / ".github" / "workflows" + + +def workflow(name): + return (WORKFLOW_DIR / name).read_text(encoding="utf-8") + + +def job_block(contents, name): + match = re.search( + rf"^ {re.escape(name)}:\n(?P.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", + contents, + flags=re.MULTILINE | re.DOTALL, + ) + assert match is not None, f"missing job {name}" + return match.group("body") + + +def assert_no_write_permissions(contents, context): + assert not re.search(r"^\s+[a-z-]+: write$", contents, re.MULTILINE), ( + f"{context} must not receive write-capable permissions" + ) + + +def main(): + workflows = {path.name: path.read_text(encoding="utf-8") for path in WORKFLOW_DIR.glob("*.yml")} + + for name, contents in workflows.items(): + assert "uses: actions/cache@" not in contents, ( + f"{name} must use explicit cache restore/save actions" + ) + + lines = contents.splitlines() + for index, line in enumerate(lines): + if re.match(r"^\s+ref:", line) and "pull_request.head.sha" in line: + checkout = "\n".join(lines[index:index + 8]) + assert "persist-credentials: false" in checkout, ( + f"{name}:{index + 1} must not persist checkout credentials" + ) + assert "allow-unsafe-pr-checkout:" in checkout, ( + f"{name}:{index + 1} must explicitly acknowledge the isolated PR checkout" + ) + + if "uses: actions/cache/save@" in line: + save_step = "\n".join(lines[max(0, index - 8):index + 1]) + assert any( + guard in save_step + for guard in ( + "github.event_name == 'push'", + "github.event_name == 'schedule'", + "inputs.trusted", + ) + ), f"{name}:{index + 1} cache save lacks a positive trusted-run guard" + + build = workflow("build.yml") + assert_no_write_permissions(build.split("\njobs:", 1)[0], "build.yml defaults") + assert build.count("packages: write") == 2 + for name in ("container-publish", "container-slim-publish"): + block = job_block(build, name) + assert "github.event_name == 'push'" in block + assert "packages: write" in block + for name in ("container", "container-slim"): + block = job_block(build, name) + assert "permissions: {}" in block + assert "github.event.repository.default_branch" in block + + container = workflow("build-container.yml") + assert container.count("if: github.event_name == 'push'") == 3 + assert "pull_request.head.sha" not in container + + for name in ( + "build-depends.yml", + "build-src.yml", + "cache-depends-sources.yml", + "lint.yml", + "test-src.yml", + ): + assert_no_write_permissions(workflow(name), name) + + guix = workflow("guix-build.yml") + assert_no_write_permissions(guix.split("\njobs:", 1)[0], "guix-build.yml defaults") + assert "pull_request.head.sha" in job_block(guix, "build-pr") + assert "trusted: false" in job_block(guix, "build-pr") + assert_no_write_permissions(job_block(guix, "build-pr"), "Guix PR build") + assert "packages: write" in job_block(guix, "build-image") + assert "github.event_name == 'pull_request_target'" not in job_block(guix, "build-image") + assert "trusted: true" in job_block(guix, "build-trusted") + assert "id-token: write" in job_block(guix, "build-trusted") + assert "attestations: write" in job_block(guix, "build-trusted") + + guix_worker = workflow("guix-build-worker.yml") + assert_no_write_permissions(guix_worker, "Guix worker") + assert "allow-unsafe-pr-checkout:" in guix_worker + assert "if: inputs.trusted" in guix_worker + + +if __name__ == "__main__": + main() From a7bee7736cd8e4c6401342850938fa9680da8675 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 17:49:52 -0500 Subject: [PATCH 03/10] ci: tighten workflow trust checks Use base-branch trusted images for pull requests, share depends caches across equivalent runner providers, and make the policy lint verify positive cache guards and indirect PR checkouts. Remove obsolete event parsing from runner selection. Co-Authored-By: Claude --- .github/workflows/build-depends.yml | 9 +++-- .github/workflows/build.yml | 14 +++++--- .github/workflows/guix-build.yml | 7 ++-- .github/workflows/select_dynamic_runner.py | 26 -------------- .../workflows/test_select_dynamic_runner.py | 4 --- test/lint/lint-ci-workflow-security.py | 36 +++++++++++++------ 6 files changed, 46 insertions(+), 50 deletions(-) diff --git a/.github/workflows/build-depends.yml b/.github/workflows/build-depends.yml index f06e46cde7ce..d4e30dd04348 100644 --- a/.github/workflows/build-depends.yml +++ b/.github/workflows/build-depends.yml @@ -69,17 +69,20 @@ jobs: - name: Compute cache key id: setup + env: + BUILD_TARGET: ${{ inputs.build-target }} + BASE_IMAGE_DIGEST: ${{ inputs.base-image-digest }} run: | - BUILD_TARGET="${{ inputs.build-target }}" source ./ci/dash/matrix.sh echo "DEP_OPTS=${DEP_OPTS}" >> "${GITHUB_OUTPUT}" echo "HOST=${HOST}" >> "${GITHUB_OUTPUT}" - echo "RUNNER_ARCH=$(uname -m)" >> "${GITHUB_OUTPUT}" + RUNNER_ARCH="$(uname -m)" + echo "RUNNER_ARCH=${RUNNER_ARCH}" >> "${GITHUB_OUTPUT}" DEP_HASH="$(echo -n "${BUILD_TARGET}" "${DEP_OPTS}" "${HOST}" | sha256sum | head -c 64)" echo "DEP_HASH=${DEP_HASH}" >> "${GITHUB_OUTPUT}" DOCKERFILE_HASH="${{ hashFiles('contrib/containers/ci/ci.Dockerfile', 'contrib/containers/ci/ci-slim.Dockerfile') }}" PACKAGES_HASH="${{ hashFiles('depends/packages/*', 'depends/Makefile') }}" - CACHE_KEY_PREFIX="depends-${DOCKERFILE_HASH}-${{ inputs.base-image-digest }}-${{ inputs.runs-on }}-${{ inputs.build-target }}" + CACHE_KEY_PREFIX="depends-${DOCKERFILE_HASH}-${BASE_IMAGE_DIGEST}-${RUNNER_ARCH}-${BUILD_TARGET}" CACHE_KEY="${CACHE_KEY_PREFIX}-${DEP_HASH}-${PACKAGES_HASH}" echo "cache-key-prefix=${CACHE_KEY_PREFIX}" >> "${GITHUB_OUTPUT}" echo "cache-key=${CACHE_KEY}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e46c64ab63d4..0bfa5d90bcf2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -123,13 +123,16 @@ jobs: - name: Select trusted container id: select env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + BASE_BRANCH: ${{ github.base_ref }} EVENT_NAME: ${{ github.event_name }} PUBLISHED_PATH: ${{ needs.container-publish.outputs.path }} REPOSITORY: ${{ github.repository }} run: | if [[ "${EVENT_NAME}" == "pull_request_target" ]]; then - echo "path=ghcr.io/${REPOSITORY,,}/dashcore-ci-runner:${DEFAULT_BRANCH,,}" >> "${GITHUB_OUTPUT}" + # Match build-container's ${GITHUB_REF##*/} basename so slash- + # containing base branches resolve to the published tag. + IMAGE_TAG="$(echo "${BASE_BRANCH##*/}" | tr '[:upper:]' '[:lower:]')" + echo "path=ghcr.io/${REPOSITORY,,}/dashcore-ci-runner:${IMAGE_TAG}" >> "${GITHUB_OUTPUT}" else echo "path=${PUBLISHED_PATH}" >> "${GITHUB_OUTPUT}" fi @@ -164,13 +167,16 @@ jobs: - name: Select trusted slim container id: select env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + BASE_BRANCH: ${{ github.base_ref }} EVENT_NAME: ${{ github.event_name }} PUBLISHED_PATH: ${{ needs.container-slim-publish.outputs.path }} REPOSITORY: ${{ github.repository }} run: | if [[ "${EVENT_NAME}" == "pull_request_target" ]]; then - echo "path=ghcr.io/${REPOSITORY,,}/dashcore-ci-slim:${DEFAULT_BRANCH,,}" >> "${GITHUB_OUTPUT}" + # Match build-container's ${GITHUB_REF##*/} basename so slash- + # containing base branches resolve to the published tag. + IMAGE_TAG="$(echo "${BASE_BRANCH##*/}" | tr '[:upper:]' '[:lower:]')" + echo "path=ghcr.io/${REPOSITORY,,}/dashcore-ci-slim:${IMAGE_TAG}" >> "${GITHUB_OUTPUT}" else echo "path=${PUBLISHED_PATH}" >> "${GITHUB_OUTPUT}" fi diff --git a/.github/workflows/guix-build.yml b/.github/workflows/guix-build.yml index e540b2f706a9..8f379a14a162 100644 --- a/.github/workflows/guix-build.yml +++ b/.github/workflows/guix-build.yml @@ -82,10 +82,13 @@ jobs: - name: Select trusted Guix builder id: resolve env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + BASE_BRANCH: ${{ github.base_ref }} REPOSITORY: ${{ github.repository }} run: | - echo "path=ghcr.io/${REPOSITORY,,}/dashcore-guix-builder:${DEFAULT_BRANCH,,}" >> "${GITHUB_OUTPUT}" + # Match build-image's ${GITHUB_REF##*/} basename so slash-containing + # base branches (e.g. release/foo) resolve to the published tag. + IMAGE_TAG="$(echo "${BASE_BRANCH##*/}" | tr '[:upper:]' '[:lower:]')" + echo "path=ghcr.io/${REPOSITORY,,}/dashcore-guix-builder:${IMAGE_TAG}" >> "${GITHUB_OUTPUT}" build-pr: needs: resolve-pr-image diff --git a/.github/workflows/select_dynamic_runner.py b/.github/workflows/select_dynamic_runner.py index 6a5c94cdb1c7..56bf3d8770a2 100644 --- a/.github/workflows/select_dynamic_runner.py +++ b/.github/workflows/select_dynamic_runner.py @@ -126,14 +126,8 @@ def count_queued_jobs( return queued_jobs -def load_event(event_path: str) -> Dict: - with open(event_path, "r", encoding="utf-8") as fh: - return json.load(fh) - - def select_runners( event_name: str, - event: Dict, threshold: int, arm64_threshold: int, runner_amd64_var: str, @@ -150,7 +144,6 @@ def select_runners( "use_blacksmith_arm64": "false", "backlog_count": "not-measured", "decision_reason": "untrusted-pr:github-hosted", - "label_override": "false", } backlog_count = "unknown" @@ -218,7 +211,6 @@ def select_runners( "use_blacksmith_arm64": "true" if use_blacksmith_arm64 else "false", "backlog_count": backlog_count, "decision_reason": ";".join(decision_parts), - "label_override": "false", } @@ -228,8 +220,6 @@ def write_github_output(path: Optional[str], outputs: Dict[str, str]) -> None: with open(path, "a", encoding="utf-8") as fh: for key, value in outputs.items(): - if key == "label_override": - continue fh.write("{}={}\n".format(key, value)) @@ -239,11 +229,6 @@ def write_step_summary(path: Optional[str], outputs: Dict[str, str]) -> None: with open(path, "a", encoding="utf-8") as fh: fh.write("### Runner selection\n") - fh.write( - "- PR label override: {}\n".format( - "yes" if outputs["label_override"] == "true" else "no" - ) - ) fh.write("- Aggregated queued jobs: {}\n".format(outputs["backlog_count"])) fh.write( "- Use Blacksmith amd64: {}\n".format( @@ -275,11 +260,6 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: default=os.environ.get("GITHUB_EVENT_NAME", ""), help="GitHub event name", ) - parser.add_argument( - "--event-path", - default=os.environ.get("GITHUB_EVENT_PATH", ""), - help="Path to GitHub event payload JSON", - ) parser.add_argument( "--backlog-threshold", type=int, @@ -327,17 +307,11 @@ def main(argv: Sequence[str]) -> int: if not args.event_name: print("error: missing event name", file=sys.stderr) return 1 - if not args.event_path: - print("error: missing event path", file=sys.stderr) - return 1 - repos = tuple(args.repos or DEFAULT_REPOS) - event = load_event(args.event_path) fetch_json = lambda url: request_json(url, args.token) outputs = select_runners( event_name=args.event_name, - event=event, threshold=args.backlog_threshold, arm64_threshold=args.arm64_backlog_threshold, runner_amd64_var=args.runner_amd64_var, diff --git a/.github/workflows/test_select_dynamic_runner.py b/.github/workflows/test_select_dynamic_runner.py index 49fc4734234c..86d410119636 100644 --- a/.github/workflows/test_select_dynamic_runner.py +++ b/.github/workflows/test_select_dynamic_runner.py @@ -82,7 +82,6 @@ def fetch_json(_url): outputs = MODULE.select_runners( event_name="pull_request_target", - event={"pull_request": {"labels": [{"name": "blacksmith-ci"}]}}, threshold=10, arm64_threshold=30, runner_amd64_var="self-hosted-amd64", @@ -119,7 +118,6 @@ def test_backlog_threshold_selects_blacksmith_amd64_only(self): outputs = MODULE.select_runners( event_name="push", - event={}, threshold=10, arm64_threshold=30, runner_amd64_var="blacksmith-amd64", @@ -157,7 +155,6 @@ def test_arm64_requires_higher_backlog_threshold(self): outputs = MODULE.select_runners( event_name="push", - event={}, threshold=10, arm64_threshold=30, runner_amd64_var="blacksmith-amd64", @@ -181,7 +178,6 @@ def fetch_json(_url): outputs = MODULE.select_runners( event_name="push", - event={}, threshold=10, arm64_threshold=30, runner_amd64_var="blacksmith-amd64", diff --git a/test/lint/lint-ci-workflow-security.py b/test/lint/lint-ci-workflow-security.py index 726c68a3e78d..190788cd374d 100755 --- a/test/lint/lint-ci-workflow-security.py +++ b/test/lint/lint-ci-workflow-security.py @@ -27,6 +27,16 @@ def job_block(contents, name): return match.group("body") +def step_block(lines, index): + start = index + while start > 0 and not lines[start].startswith(" - name:"): + start -= 1 + end = index + 1 + while end < len(lines) and not lines[end].startswith(" - name:"): + end += 1 + return "\n".join(lines[start:end]) + + def assert_no_write_permissions(contents, context): assert not re.search(r"^\s+[a-z-]+: write$", contents, re.MULTILINE), ( f"{context} must not receive write-capable permissions" @@ -53,15 +63,17 @@ def main(): ) if "uses: actions/cache/save@" in line: - save_step = "\n".join(lines[max(0, index - 8):index + 1]) - assert any( - guard in save_step - for guard in ( - "github.event_name == 'push'", - "github.event_name == 'schedule'", - "inputs.trusted", - ) - ), f"{name}:{index + 1} cache save lacks a positive trusted-run guard" + save_step = step_block(lines, index) + condition = save_step.split("uses: actions/cache/save@", 1)[0] + trusted_event = re.search( + r"github\.event_name\s*==\s*'(?:push|schedule)'", condition + ) + trusted_input = re.search( + r"^\s*if:\s*inputs\.trusted(?:\s*&&|\s*$)", condition, re.MULTILINE + ) + assert trusted_event or trusted_input, ( + f"{name}:{index + 1} cache save lacks a positive trusted-run guard" + ) build = workflow("build.yml") assert_no_write_permissions(build.split("\njobs:", 1)[0], "build.yml defaults") @@ -73,7 +85,7 @@ def main(): for name in ("container", "container-slim"): block = job_block(build, name) assert "permissions: {}" in block - assert "github.event.repository.default_branch" in block + assert "github.base_ref" in block container = workflow("build-container.yml") assert container.count("if: github.event_name == 'push'") == 3 @@ -101,7 +113,9 @@ def main(): guix_worker = workflow("guix-build-worker.yml") assert_no_write_permissions(guix_worker, "Guix worker") - assert "allow-unsafe-pr-checkout:" in guix_worker + assert "ref: ${{ inputs.source-ref }}" in guix_worker + assert "persist-credentials: false" in guix_worker + assert "allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}" in guix_worker assert "if: inputs.trusted" in guix_worker From efac1618a8b85d239f14b5ad47091ea61393f5dc Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 18:09:42 -0500 Subject: [PATCH 04/10] fix(ci): authenticate trusted container pulls Grant read-only package access to jobs consuming private GHCR images. Supply job-container credentials without exposing a write token, and pre-pull the Guix builder before checking out untrusted code so registry credentials can be removed first. Co-Authored-By: Claude --- .github/workflows/build-depends.yml | 4 + .github/workflows/build-src.yml | 4 + .github/workflows/build.yml | 1 + .github/workflows/guix-build-worker.yml | 16 + .github/workflows/guix-build.yml | 3 + .github/workflows/lint.yml | 4 + .github/workflows/test-src.yml | 4 + ci/dash/lint.sh | 11 + ci/lint/04_install.sh | 1 + contrib/containers/ci/ci-slim.Dockerfile | 1 + test/lint/lint-ci-workflow-security.py | 534 +++++++++++++++++++---- 11 files changed, 505 insertions(+), 78 deletions(-) diff --git a/.github/workflows/build-depends.yml b/.github/workflows/build-depends.yml index d4e30dd04348..1349bff2a89c 100644 --- a/.github/workflows/build-depends.yml +++ b/.github/workflows/build-depends.yml @@ -142,10 +142,14 @@ jobs: permissions: actions: read contents: read + packages: read outputs: built-artifact: depends-built-${{ inputs.build-target }} container: image: ${{ inputs.container-path }} + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} options: --user root steps: - name: Checkout code diff --git a/.github/workflows/build-src.yml b/.github/workflows/build-src.yml index d8bfb87312e7..7cee1b57e2c7 100644 --- a/.github/workflows/build-src.yml +++ b/.github/workflows/build-src.yml @@ -50,10 +50,14 @@ jobs: permissions: actions: read contents: read + packages: read outputs: key: ${{ steps.bundle.outputs.key }} container: image: ${{ inputs.container-path }} + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} options: --user root steps: - name: Checkout code diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0bfa5d90bcf2..ad3f10ba00bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,7 @@ on: permissions: actions: read contents: read + packages: read concurrency: group: | diff --git a/.github/workflows/guix-build-worker.yml b/.github/workflows/guix-build-worker.yml index 2402efcce832..52238a540afa 100644 --- a/.github/workflows/guix-build-worker.yml +++ b/.github/workflows/guix-build-worker.yml @@ -29,6 +29,22 @@ jobs: run: | sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + - name: Login to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Pull trusted Guix builder + env: + CONTAINER_PATH: ${{ inputs.container-path }} + run: docker pull "${CONTAINER_PATH}" + + - name: Remove registry credentials + if: always() + run: docker logout ghcr.io + - name: Checkout uses: actions/checkout@v6 with: diff --git a/.github/workflows/guix-build.yml b/.github/workflows/guix-build.yml index 8f379a14a162..35b597180497 100644 --- a/.github/workflows/guix-build.yml +++ b/.github/workflows/guix-build.yml @@ -2,6 +2,7 @@ name: Guix Build permissions: contents: read + packages: read on: pull_request_target: @@ -95,6 +96,7 @@ jobs: permissions: actions: read contents: read + packages: read uses: ./.github/workflows/guix-build-worker.yml with: source-ref: ${{ github.event.pull_request.head.sha }} @@ -106,6 +108,7 @@ jobs: permissions: actions: read contents: read + packages: read id-token: write attestations: write uses: ./.github/workflows/guix-build-worker.yml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 809177d059ed..f7a28aaf9536 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -19,8 +19,12 @@ jobs: permissions: actions: read contents: read + packages: read container: image: ${{ inputs.container-path }} + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} options: --user root steps: - name: Checkout code diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index e4f3eefd07e9..9b5742bf42a5 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -31,8 +31,12 @@ jobs: permissions: actions: read contents: read + packages: read container: image: ${{ inputs.container-path }} + credentials: + username: ${{ github.actor }} + password: ${{ github.token }} options: --user root steps: - name: Checkout code diff --git a/ci/dash/lint.sh b/ci/dash/lint.sh index bc953ef07577..d281dbecb601 100755 --- a/ci/dash/lint.sh +++ b/ci/dash/lint.sh @@ -11,6 +11,17 @@ set -e source ./ci/dash/matrix.sh +# YAML-based workflow lints need PyYAML. pull_request_target reuses the trusted +# base-branch ci-slim image, which may lag Dockerfile changes on the PR head, so +# install the pinned dependency when the image does not already provide it. +if ! python3 -c "import yaml" >/dev/null 2>&1; then + if command -v uv >/dev/null 2>&1; then + uv pip install --system --break-system-packages "PyYAML==6.0.2" + else + python3 -m pip install --user "PyYAML==6.0.2" + fi +fi + # Check commit scripts for PRs if [ "$PULL_REQUEST" != "false" ]; then test/lint/commit-script-check.sh "$COMMIT_RANGE" diff --git a/ci/lint/04_install.sh b/ci/lint/04_install.sh index 9ca5ebe37216..3f221af0df7c 100755 --- a/ci/lint/04_install.sh +++ b/ci/lint/04_install.sh @@ -38,6 +38,7 @@ ${CI_RETRY_EXE} pip3 install codespell==2.2.1 ${CI_RETRY_EXE} pip3 install flake8==5.0.4 ${CI_RETRY_EXE} pip3 install lief==0.13.2 ${CI_RETRY_EXE} pip3 install mypy==0.981 +${CI_RETRY_EXE} pip3 install PyYAML==6.0.2 ${CI_RETRY_EXE} pip3 install pyzmq==24.0.1 ${CI_RETRY_EXE} pip3 install vulture==2.6 diff --git a/contrib/containers/ci/ci-slim.Dockerfile b/contrib/containers/ci/ci-slim.Dockerfile index 8436fd14dfa3..26c1bbafeb62 100644 --- a/contrib/containers/ci/ci-slim.Dockerfile +++ b/contrib/containers/ci/ci-slim.Dockerfile @@ -82,6 +82,7 @@ RUN uv pip install --system --break-system-packages \ jinja2 \ lief==0.13.2 \ mypy==0.981 \ + PyYAML==6.0.2 \ pyzmq==24.0.1 \ vulture==2.6 diff --git a/test/lint/lint-ci-workflow-security.py b/test/lint/lint-ci-workflow-security.py index 190788cd374d..e69315f969e6 100755 --- a/test/lint/lint-ci-workflow-security.py +++ b/test/lint/lint-ci-workflow-security.py @@ -3,93 +3,256 @@ # Distributed under the MIT software license, see the accompanying # file COPYING or http://www.opensource.org/licenses/mit-license.php. -"""Enforce the trust boundary for workflows that execute pull request code.""" +"""Enforce the trust boundary for workflows that execute pull request code. + +Workflows are parsed as YAML so equivalent forms (flow mappings, quoted keys, +inline comments) cannot bypass the permission, checkout, and cache-save checks. +""" + +from __future__ import annotations import re +import sys +import tempfile from pathlib import Path +from typing import Any + +import yaml ROOT = Path(__file__).resolve().parents[2] WORKFLOW_DIR = ROOT / ".github" / "workflows" -def workflow(name): - return (WORKFLOW_DIR / name).read_text(encoding="utf-8") +def normalize_gha(node: Any) -> Any: + """Repair YAML 1.1 bool coercion of the GitHub Actions `on` key. + Keep boolean values as real bools (needed for `persist-credentials: false` + and `trusted: false`) while restoring the workflow trigger key name. + """ + if isinstance(node, dict): + fixed = {} + for key, value in node.items(): + if key is True: + key = "on" + elif key is False: + key = "off" + fixed[key] = normalize_gha(value) + return fixed + if isinstance(node, list): + return [normalize_gha(item) for item in node] + return node -def job_block(contents, name): - match = re.search( - rf"^ {re.escape(name)}:\n(?P.*?)(?=^ [A-Za-z0-9_-]+:\n|\Z)", - contents, - flags=re.MULTILINE | re.DOTALL, - ) - assert match is not None, f"missing job {name}" - return match.group("body") +def load_workflow(path: Path) -> Any: + return normalize_gha(yaml.safe_load(path.read_text(encoding="utf-8"))) -def step_block(lines, index): - start = index - while start > 0 and not lines[start].startswith(" - name:"): - start -= 1 - end = index + 1 - while end < len(lines) and not lines[end].startswith(" - name:"): - end += 1 - return "\n".join(lines[start:end]) +def workflow_data(name: str, workflows: dict[str, Any]) -> Any: + assert name in workflows, f"missing workflow {name}" + return workflows[name] -def assert_no_write_permissions(contents, context): - assert not re.search(r"^\s+[a-z-]+: write$", contents, re.MULTILINE), ( - f"{context} must not receive write-capable permissions" - ) +def job(data: Any, name: str) -> dict: + jobs = (data or {}).get("jobs") or {} + assert name in jobs, f"missing job {name}" + block = jobs[name] + assert isinstance(block, dict), f"job {name} is not a mapping" + return block -def main(): - workflows = {path.name: path.read_text(encoding="utf-8") for path in WORKFLOW_DIR.glob("*.yml")} - for name, contents in workflows.items(): - assert "uses: actions/cache@" not in contents, ( - f"{name} must use explicit cache restore/save actions" - ) +def permissions_map(node: Any) -> dict[str, str] | None: + """Return a permissions mapping, or None if permissions are absent.""" + if node is None: + return None + if not isinstance(node, dict) or "permissions" not in node: + return None + perms = node["permissions"] + if perms is None or perms == {}: + return {} + if isinstance(perms, str): + # Top-level shorthand like `permissions: read-all` / `write-all`. + return {"__shorthand__": perms} + assert isinstance(perms, dict), f"permissions must be a mapping, got {perms!r}" + return {str(k): str(v) for k, v in perms.items()} + + +def assert_no_write_permissions(node: Any, context: str) -> None: + perms = permissions_map(node) + if perms is None: + return + if perms.get("__shorthand__") in ("write-all", "write"): + raise AssertionError(f"{context} must not receive write-capable permissions") + for key, value in perms.items(): + if key == "__shorthand__": + continue + if value == "write": + raise AssertionError( + f"{context} must not receive write-capable permissions ({key}: write)" + ) - lines = contents.splitlines() - for index, line in enumerate(lines): - if re.match(r"^\s+ref:", line) and "pull_request.head.sha" in line: - checkout = "\n".join(lines[index:index + 8]) - assert "persist-credentials: false" in checkout, ( - f"{name}:{index + 1} must not persist checkout credentials" + +def walk_steps(data: Any): + """Yield (job_name, step_dict) for every step in the workflow.""" + jobs = (data or {}).get("jobs") or {} + if not isinstance(jobs, dict): + return + for job_name, job_body in jobs.items(): + if not isinstance(job_body, dict): + continue + steps = job_body.get("steps") or [] + if not isinstance(steps, list): + continue + for step in steps: + if isinstance(step, dict): + yield job_name, step + + +def uses_action(step: dict, prefix: str) -> bool: + uses = step.get("uses") + return isinstance(uses, str) and uses.startswith(prefix) + + +def check_workflow_invariants(name: str, data: Any) -> None: + for job_name, step in walk_steps(data): + uses = step.get("uses") + if isinstance(uses, str) and uses.startswith("actions/cache@"): + raise AssertionError( + f"{name} job {job_name} must use explicit cache restore/save actions, " + f"not combined actions/cache" + ) + + if uses_action(step, "actions/cache/save@"): + condition = step.get("if") + if not isinstance(condition, str): + raise AssertionError( + f"{name} job {job_name}: cache save lacks a positive trusted-run guard" ) - assert "allow-unsafe-pr-checkout:" in checkout, ( - f"{name}:{index + 1} must explicitly acknowledge the isolated PR checkout" + trusted_event = re.search( + r"github\.event_name\s*==\s*'(?:push|schedule)'", condition + ) + trusted_input = re.search( + r"(?:^|[\s&(])inputs\.trusted(?:\s*&&|\s*$|\s*\))", condition + ) + if not (trusted_event or trusted_input): + raise AssertionError( + f"{name} job {job_name}: cache save lacks a positive trusted-run guard" ) - if "uses: actions/cache/save@" in line: - save_step = step_block(lines, index) - condition = save_step.split("uses: actions/cache/save@", 1)[0] - trusted_event = re.search( - r"github\.event_name\s*==\s*'(?:push|schedule)'", condition + with_block = step.get("with") or {} + if not isinstance(with_block, dict): + continue + ref = with_block.get("ref") + if isinstance(ref, str) and "pull_request.head.sha" in ref: + if with_block.get("persist-credentials") is not False: + raise AssertionError( + f"{name} job {job_name}: must not persist checkout credentials " + f"for isolated PR checkout" ) - trusted_input = re.search( - r"^\s*if:\s*inputs\.trusted(?:\s*&&|\s*$)", condition, re.MULTILINE - ) - assert trusted_event or trusted_input, ( - f"{name}:{index + 1} cache save lacks a positive trusted-run guard" + if "allow-unsafe-pr-checkout" not in with_block: + raise AssertionError( + f"{name} job {job_name}: must explicitly acknowledge the isolated " + f"PR checkout" ) - build = workflow("build.yml") - assert_no_write_permissions(build.split("\njobs:", 1)[0], "build.yml defaults") - assert build.count("packages: write") == 2 + +def contains_expr(node: Any, needle: str) -> bool: + if isinstance(node, str): + return needle in node + if isinstance(node, dict): + return any(contains_expr(v, needle) for v in node.values()) + if isinstance(node, list): + return any(contains_expr(v, needle) for v in node) + return False + + +def count_expr(node: Any, needle: str) -> int: + if isinstance(node, str): + return node.count(needle) + if isinstance(node, dict): + return sum(count_expr(v, needle) for v in node.values()) + if isinstance(node, list): + return sum(count_expr(v, needle) for v in node) + return 0 + + +def has_packages_read(node: Any) -> bool: + perms = permissions_map(node) or {} + return perms.get("packages") == "read" + + +def has_container_credentials(node: Any) -> bool: + """True if any job uses container.credentials.password: github.token.""" + jobs = (node or {}).get("jobs") or {} + if not isinstance(jobs, dict): + return False + for job_body in jobs.values(): + if not isinstance(job_body, dict): + continue + container = job_body.get("container") + if not isinstance(container, dict): + continue + creds = container.get("credentials") + if not isinstance(creds, dict): + continue + password = creds.get("password") + if isinstance(password, str) and "github.token" in password: + return True + return False + + +def main() -> None: + workflow_paths = sorted(WORKFLOW_DIR.glob("*.yml")) + assert workflow_paths, f"no workflows under {WORKFLOW_DIR}" + workflows: dict[str, Any] = {} + for path in workflow_paths: + try: + workflows[path.name] = load_workflow(path) + except yaml.YAMLError as exc: + raise AssertionError(f"{path.name}: YAML parse error: {exc}") from exc + + for name, data in workflows.items(): + check_workflow_invariants(name, data) + + build = workflow_data("build.yml", workflows) + assert_no_write_permissions(build, "build.yml defaults") + assert has_packages_read(build), "build.yml defaults must include packages: read" + # Exactly two jobs may escalate to packages: write (container publishers). + write_jobs = [] + for job_name, job_body in (build.get("jobs") or {}).items(): + perms = permissions_map(job_body) or {} + if perms.get("packages") == "write": + write_jobs.append(job_name) + assert write_jobs == ["container-publish", "container-slim-publish"] or set( + write_jobs + ) == {"container-publish", "container-slim-publish"}, ( + f"build.yml packages: write jobs unexpected: {write_jobs}" + ) + assert len(write_jobs) == 2 + for name in ("container-publish", "container-slim-publish"): - block = job_block(build, name) - assert "github.event_name == 'push'" in block - assert "packages: write" in block + block = job(build, name) + assert block.get("if") and "github.event_name == 'push'" in str(block.get("if")), ( + f"{name} must be push-only" + ) + perms = permissions_map(block) or {} + assert perms.get("packages") == "write", f"{name} needs packages: write" + for name in ("container", "container-slim"): - block = job_block(build, name) - assert "permissions: {}" in block - assert "github.base_ref" in block + block = job(build, name) + perms = permissions_map(block) + assert perms == {}, f"{name} must set permissions: {{}}" + assert contains_expr(block, "github.base_ref"), ( + f"{name} must select images from github.base_ref" + ) - container = workflow("build-container.yml") - assert container.count("if: github.event_name == 'push'") == 3 - assert "pull_request.head.sha" not in container + container = workflow_data("build-container.yml", workflows) + assert count_expr(container, "github.event_name == 'push'") == 3, ( + "build-container.yml must gate publish paths on push" + ) + assert not contains_expr(container, "pull_request.head.sha"), ( + "build-container.yml must not check out PR heads" + ) for name in ( "build-depends.yml", @@ -98,26 +261,241 @@ def main(): "lint.yml", "test-src.yml", ): - assert_no_write_permissions(workflow(name), name) - - guix = workflow("guix-build.yml") - assert_no_write_permissions(guix.split("\njobs:", 1)[0], "guix-build.yml defaults") - assert "pull_request.head.sha" in job_block(guix, "build-pr") - assert "trusted: false" in job_block(guix, "build-pr") - assert_no_write_permissions(job_block(guix, "build-pr"), "Guix PR build") - assert "packages: write" in job_block(guix, "build-image") - assert "github.event_name == 'pull_request_target'" not in job_block(guix, "build-image") - assert "trusted: true" in job_block(guix, "build-trusted") - assert "id-token: write" in job_block(guix, "build-trusted") - assert "attestations: write" in job_block(guix, "build-trusted") - - guix_worker = workflow("guix-build-worker.yml") + assert_no_write_permissions(workflow_data(name, workflows), name) + + for name in ("build-depends.yml", "build-src.yml", "lint.yml", "test-src.yml"): + data = workflow_data(name, workflows) + assert has_packages_read(data) or any( + has_packages_read(j) for j in (data.get("jobs") or {}).values() + if isinstance(j, dict) + ), f"{name} must grant packages: read" + assert has_container_credentials(data), ( + f"{name} must supply container credentials with github.token" + ) + + guix = workflow_data("guix-build.yml", workflows) + assert_no_write_permissions(guix, "guix-build.yml defaults") + assert has_packages_read(guix), "guix-build.yml defaults must include packages: read" + + build_pr = job(guix, "build-pr") + assert contains_expr(build_pr, "pull_request.head.sha") + assert build_pr.get("with", {}).get("trusted") is False + pr_perms = permissions_map(build_pr) or {} + assert pr_perms.get("packages") == "read" + assert_no_write_permissions(build_pr, "Guix PR build") + + build_image = job(guix, "build-image") + image_perms = permissions_map(build_image) or {} + assert image_perms.get("packages") == "write" + assert not contains_expr(build_image, "github.event_name == 'pull_request_target'") + + build_trusted = job(guix, "build-trusted") + assert build_trusted.get("with", {}).get("trusted") is True + trusted_perms = permissions_map(build_trusted) or {} + assert trusted_perms.get("packages") == "read" + assert trusted_perms.get("id-token") == "write" + assert trusted_perms.get("attestations") == "write" + + guix_worker = workflow_data("guix-build-worker.yml", workflows) assert_no_write_permissions(guix_worker, "Guix worker") - assert "ref: ${{ inputs.source-ref }}" in guix_worker - assert "persist-credentials: false" in guix_worker - assert "allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}" in guix_worker - assert "if: inputs.trusted" in guix_worker + # Ensure the worker entrypoint job still exists (walk_steps covers its steps). + job(guix_worker, "build") + # Checkout isolation + checkout_steps = [ + s for _, s in walk_steps(guix_worker) + if uses_action(s, "actions/checkout@") + ] + assert checkout_steps, "guix-build-worker must check out source" + for step in checkout_steps: + with_block = step.get("with") or {} + assert with_block.get("ref") == "${{ inputs.source-ref }}" + assert with_block.get("persist-credentials") is False + assert with_block.get("allow-unsafe-pr-checkout") == ( + "${{ github.event_name == 'pull_request_target' }}" + ) + + # Registry credentials removed before untrusted code runs further? At least + # present as docker logout and password: github.token for the pull. + assert contains_expr(guix_worker, "github.token") + assert any( + isinstance(s.get("run"), str) and "docker logout ghcr.io" in s["run"] + for _, s in walk_steps(guix_worker) + ) + # Cache saves / attestations gated on inputs.trusted + for _, step in walk_steps(guix_worker): + if uses_action(step, "actions/cache/save@") or uses_action(step, "actions/attest@"): + condition = step.get("if") + assert isinstance(condition, str) and "inputs.trusted" in condition, ( + "Guix worker privileged steps must be gated on inputs.trusted" + ) + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def self_test() -> None: + """Fixtures proving YAML-equivalent bypasses are rejected.""" + # Flow-mapping write permissions must be detected. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "flow-write.yml" + _write( + path, + "on: push\npermissions: {contents: write}\njobs:\n x:\n runs-on: ubuntu-latest\n steps: []\n", + ) + data = load_workflow(path) + try: + assert_no_write_permissions(data, "flow-write") + except AssertionError: + pass + else: + raise AssertionError("flow-mapping permissions: write was not detected") + + # Quoted key write permissions must be detected. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "quoted-write.yml" + _write( + path, + 'on: push\npermissions:\n "contents": write\njobs:\n x:\n runs-on: ubuntu-latest\n steps: []\n', + ) + data = load_workflow(path) + try: + assert_no_write_permissions(data, "quoted-write") + except AssertionError: + pass + else: + raise AssertionError("quoted-key permissions write was not detected") + + # Inline comment after write value must still be treated as write. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "comment-write.yml" + _write( + path, + "on: push\npermissions:\n contents: write # temporary\njobs:\n x:\n runs-on: ubuntu-latest\n steps: []\n", + ) + data = load_workflow(path) + try: + assert_no_write_permissions(data, "comment-write") + except AssertionError: + pass + else: + raise AssertionError("comment-suffixed write permission was not detected") + + # Cache save without trusted guard must fail. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "cache.yml" + _write( + path, + ( + "on: push\n" + "jobs:\n" + " x:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/cache/save@v5\n" + " with:\n" + " path: x\n" + " key: k\n" + ), + ) + data = load_workflow(path) + try: + check_workflow_invariants(path.name, data) + except AssertionError as exc: + assert "trusted-run guard" in str(exc) + else: + raise AssertionError("unguarded cache save was not rejected") + + # PR checkout without persist-credentials: false must fail. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "checkout.yml" + _write( + path, + ( + "on: pull_request_target\n" + "jobs:\n" + " x:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v6\n" + " with:\n" + " ref: ${{ github.event.pull_request.head.sha }}\n" + ), + ) + data = load_workflow(path) + try: + check_workflow_invariants(path.name, data) + except AssertionError as exc: + assert "persist checkout credentials" in str(exc) + else: + raise AssertionError("PR checkout without persist-credentials: false passed") + + # Combined actions/cache must fail. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "combined-cache.yml" + _write( + path, + ( + "on: push\n" + "jobs:\n" + " x:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/cache@v5\n" + " with:\n" + " path: x\n" + " key: k\n" + ), + ) + data = load_workflow(path) + try: + check_workflow_invariants(path.name, data) + except AssertionError as exc: + assert "explicit cache restore/save" in str(exc) + else: + raise AssertionError("combined actions/cache was not rejected") + + # `on` must not remain coerced to True after normalize_gha. + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "on-key.yml" + _write(path, "on: push\njobs: {}\n") + data = load_workflow(path) + assert "on" in data and True not in data, data + + # Boolean false values must remain bools, not the string "false". + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "bool-false.yml" + _write( + path, + ( + "on: pull_request_target\n" + "jobs:\n" + " x:\n" + " runs-on: ubuntu-latest\n" + " steps:\n" + " - uses: actions/checkout@v6\n" + " with:\n" + " ref: ${{ github.event.pull_request.head.sha }}\n" + " persist-credentials: false\n" + " allow-unsafe-pr-checkout: true\n" + ), + ) + data = load_workflow(path) + step = data["jobs"]["x"]["steps"][0] + assert step["with"]["persist-credentials"] is False + check_workflow_invariants(path.name, data) if __name__ == "__main__": - main() + try: + self_test() + except AssertionError as exc: + print(f"ERROR: lint-ci-workflow-security self-test failed: {exc}", file=sys.stderr) + sys.exit(1) + try: + main() + except AssertionError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + sys.exit(1) + print("OK: workflow trust-boundary checks passed") From e8ba3058ec78264089cbd7169c8f826a17636c31 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 18:17:51 -0500 Subject: [PATCH 05/10] ci: allow Blacksmith runners for PR builds Blacksmith runners are ephemeral and equivalent to GitHub-hosted runners for this threat model. Restore label/backlog-based dynamic selection and bootstrap RUNNER_CHECK_SKIP configuration for pull_request_target, while keeping least-privilege permissions and trusted-image boundaries. Co-Authored-By: Claude --- .github/workflows/build.yml | 5 +- .github/workflows/select_dynamic_runner.py | 50 ++++++++++++++----- .../workflows/test_select_dynamic_runner.py | 45 +++++++++++------ 3 files changed, 70 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ad3f10ba00bd..390b445b2e2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -21,9 +21,8 @@ concurrency: jobs: check-skip: name: Check skip conditions - # Untrusted PRs must stay on ephemeral GitHub-hosted runners. Trusted pushes - # may use the configured bootstrap runner. - runs-on: ${{ github.event_name == 'pull_request_target' && 'ubuntu-24.04-arm' || vars.RUNNER_CHECK_SKIP || 'ubuntu-24.04-arm' }} + # Keep the bootstrap job cheap; RUNNER_CHECK_SKIP can point to a small Blacksmith ARM runner. + runs-on: ${{ vars.RUNNER_CHECK_SKIP || 'ubuntu-24.04-arm' }} outputs: skip: ${{ steps.skip-check.outputs.skip }} runner-amd64: ${{ steps.select-runner.outputs.runner_amd64 }} diff --git a/.github/workflows/select_dynamic_runner.py b/.github/workflows/select_dynamic_runner.py index 56bf3d8770a2..03cb0184cf1d 100644 --- a/.github/workflows/select_dynamic_runner.py +++ b/.github/workflows/select_dynamic_runner.py @@ -126,8 +126,14 @@ def count_queued_jobs( return queued_jobs +def load_event(event_path: str) -> Dict: + with open(event_path, "r", encoding="utf-8") as fh: + return json.load(fh) + + def select_runners( event_name: str, + event: Dict, threshold: int, arm64_threshold: int, runner_amd64_var: str, @@ -135,16 +141,13 @@ def select_runners( fetch_json: Callable[[str], Tuple[Dict, Dict[str, str]]], repos: Sequence[str] = DEFAULT_REPOS, ) -> Dict[str, str]: - if event_name == "pull_request_target": - return { - "runner_amd64": DEFAULT_RUNNER_AMD64, - "runner_arm64": DEFAULT_RUNNER_ARM64, - "use_blacksmith": "false", - "use_blacksmith_amd64": "false", - "use_blacksmith_arm64": "false", - "backlog_count": "not-measured", - "decision_reason": "untrusted-pr:github-hosted", - } + label_names = [ + label.get("name", "") + for label in event.get("pull_request", {}).get("labels", []) + ] + label_override = ( + event_name == "pull_request_target" and "blacksmith-ci" in label_names + ) backlog_count = "unknown" backlog_count_value = None @@ -158,7 +161,9 @@ def select_runners( decision_parts = [] - if measurement_error is not None: + if label_override: + decision_parts.append("label:blacksmith-ci") + elif measurement_error is not None: decision_parts.append("metric-unavailable") else: decision_parts.append( @@ -176,10 +181,10 @@ def select_runners( ) ) - use_blacksmith_amd64 = ( + use_blacksmith_amd64 = label_override or ( measurement_error is None and backlog_count_value > threshold ) - use_blacksmith_arm64 = ( + use_blacksmith_arm64 = label_override or ( measurement_error is None and backlog_count_value > arm64_threshold ) @@ -211,6 +216,7 @@ def select_runners( "use_blacksmith_arm64": "true" if use_blacksmith_arm64 else "false", "backlog_count": backlog_count, "decision_reason": ";".join(decision_parts), + "label_override": "true" if label_override else "false", } @@ -220,6 +226,8 @@ def write_github_output(path: Optional[str], outputs: Dict[str, str]) -> None: with open(path, "a", encoding="utf-8") as fh: for key, value in outputs.items(): + if key == "label_override": + continue fh.write("{}={}\n".format(key, value)) @@ -229,6 +237,11 @@ def write_step_summary(path: Optional[str], outputs: Dict[str, str]) -> None: with open(path, "a", encoding="utf-8") as fh: fh.write("### Runner selection\n") + fh.write( + "- PR label override: {}\n".format( + "yes" if outputs["label_override"] == "true" else "no" + ) + ) fh.write("- Aggregated queued jobs: {}\n".format(outputs["backlog_count"])) fh.write( "- Use Blacksmith amd64: {}\n".format( @@ -260,6 +273,11 @@ def parse_args(argv: Sequence[str]) -> argparse.Namespace: default=os.environ.get("GITHUB_EVENT_NAME", ""), help="GitHub event name", ) + parser.add_argument( + "--event-path", + default=os.environ.get("GITHUB_EVENT_PATH", ""), + help="Path to GitHub event payload JSON", + ) parser.add_argument( "--backlog-threshold", type=int, @@ -307,11 +325,17 @@ def main(argv: Sequence[str]) -> int: if not args.event_name: print("error: missing event name", file=sys.stderr) return 1 + if not args.event_path: + print("error: missing event path", file=sys.stderr) + return 1 + repos = tuple(args.repos or DEFAULT_REPOS) + event = load_event(args.event_path) fetch_json = lambda url: request_json(url, args.token) outputs = select_runners( event_name=args.event_name, + event=event, threshold=args.backlog_threshold, arm64_threshold=args.arm64_backlog_threshold, runner_amd64_var=args.runner_amd64_var, diff --git a/.github/workflows/test_select_dynamic_runner.py b/.github/workflows/test_select_dynamic_runner.py index 86d410119636..446a33d87ece 100644 --- a/.github/workflows/test_select_dynamic_runner.py +++ b/.github/workflows/test_select_dynamic_runner.py @@ -76,27 +76,24 @@ def fetch_json(url): self.assertEqual(MODULE.count_queued_jobs(fetch_json, [repo]), 1) - def test_pull_request_target_forces_github_hosted_runners(self): - def fetch_json(_url): - self.fail("pull request runner selection must not query backlog metrics") - + def test_label_override_selects_blacksmith_even_with_low_backlog(self): outputs = MODULE.select_runners( event_name="pull_request_target", + event={"pull_request": {"labels": [{"name": "blacksmith-ci"}]}}, threshold=10, arm64_threshold=30, - runner_amd64_var="self-hosted-amd64", - runner_arm64_var="self-hosted-arm64", - fetch_json=fetch_json, + runner_amd64_var="blacksmith-amd64", + runner_arm64_var="blacksmith-arm64", + fetch_json=lambda _url: ({"workflow_runs": []}, {}), repos=["dashpay/dash"], ) - self.assertEqual(outputs["use_blacksmith"], "false") - self.assertEqual(outputs["use_blacksmith_amd64"], "false") - self.assertEqual(outputs["use_blacksmith_arm64"], "false") - self.assertEqual(outputs["runner_amd64"], MODULE.DEFAULT_RUNNER_AMD64) - self.assertEqual(outputs["runner_arm64"], MODULE.DEFAULT_RUNNER_ARM64) - self.assertEqual(outputs["backlog_count"], "not-measured") - self.assertEqual(outputs["decision_reason"], "untrusted-pr:github-hosted") + self.assertEqual(outputs["use_blacksmith"], "true") + self.assertEqual(outputs["use_blacksmith_amd64"], "true") + self.assertEqual(outputs["use_blacksmith_arm64"], "true") + self.assertEqual(outputs["runner_amd64"], "blacksmith-amd64") + self.assertEqual(outputs["runner_arm64"], "blacksmith-arm64") + self.assertIn("label:blacksmith-ci", outputs["decision_reason"]) def test_backlog_threshold_selects_blacksmith_amd64_only(self): repo = "dashpay/dash" @@ -118,6 +115,7 @@ def test_backlog_threshold_selects_blacksmith_amd64_only(self): outputs = MODULE.select_runners( event_name="push", + event={}, threshold=10, arm64_threshold=30, runner_amd64_var="blacksmith-amd64", @@ -155,6 +153,7 @@ def test_arm64_requires_higher_backlog_threshold(self): outputs = MODULE.select_runners( event_name="push", + event={}, threshold=10, arm64_threshold=30, runner_amd64_var="blacksmith-amd64", @@ -178,6 +177,7 @@ def fetch_json(_url): outputs = MODULE.select_runners( event_name="push", + event={}, threshold=10, arm64_threshold=30, runner_amd64_var="blacksmith-amd64", @@ -194,5 +194,22 @@ def fetch_json(_url): self.assertEqual(outputs["backlog_count"], "unknown") self.assertIn("metric-unavailable", outputs["decision_reason"]) + def test_missing_runner_vars_fall_back_per_arch(self): + outputs = MODULE.select_runners( + event_name="pull_request_target", + event={"pull_request": {"labels": [{"name": "blacksmith-ci"}]}}, + threshold=10, + arm64_threshold=30, + runner_amd64_var="", + runner_arm64_var="blacksmith-arm64", + fetch_json=lambda _url: ({"workflow_runs": []}, {}), + repos=["dashpay/dash"], + ) + + self.assertEqual(outputs["runner_amd64"], MODULE.DEFAULT_RUNNER_AMD64) + self.assertEqual(outputs["runner_arm64"], "blacksmith-arm64") + self.assertIn("amd64-github-fallback", outputs["decision_reason"]) + + if __name__ == "__main__": unittest.main() From e0ebaf439e73595734fea65190a90de1d7157b91 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 18:21:32 -0500 Subject: [PATCH 06/10] test(ci): cover PR backlog runner selection Ensure an unlabeled pull_request_target event still follows the dynamic backlog thresholds and may select Blacksmith runners. Co-Authored-By: Claude --- .github/workflows/test_select_dynamic_runner.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test_select_dynamic_runner.py b/.github/workflows/test_select_dynamic_runner.py index 446a33d87ece..b5c577f623bc 100644 --- a/.github/workflows/test_select_dynamic_runner.py +++ b/.github/workflows/test_select_dynamic_runner.py @@ -95,7 +95,7 @@ def test_label_override_selects_blacksmith_even_with_low_backlog(self): self.assertEqual(outputs["runner_arm64"], "blacksmith-arm64") self.assertIn("label:blacksmith-ci", outputs["decision_reason"]) - def test_backlog_threshold_selects_blacksmith_amd64_only(self): + def test_pull_request_backlog_selects_blacksmith_amd64_only(self): repo = "dashpay/dash" queued_url = ( "https://api.github.com/repos/{}/actions/runs?status=queued&per_page=100" @@ -114,8 +114,8 @@ def test_backlog_threshold_selects_blacksmith_amd64_only(self): } outputs = MODULE.select_runners( - event_name="push", - event={}, + event_name="pull_request_target", + event={"pull_request": {"labels": []}}, threshold=10, arm64_threshold=30, runner_amd64_var="blacksmith-amd64", From 0ec24b2cab3f6b3a81628bd7a9093d2d19e6e541 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 18:21:41 -0500 Subject: [PATCH 07/10] docs(ci): clarify cache warmup runner choice Remove the outdated claim that Blacksmith runners retain local cache state and describe both supported runner paths as ephemeral. Co-Authored-By: Claude --- .github/workflows/cache-depends-sources.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cache-depends-sources.yml b/.github/workflows/cache-depends-sources.yml index 8e3455f630d0..469308181c42 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -18,8 +18,8 @@ jobs: permissions: actions: read contents: read - # Intentionally keep scheduled cache warming on GitHub-hosted ARM runners. - # Blacksmith caches are expected to persist long enough without a warmup cron. + # Scheduled warmups use the default GitHub-hosted ARM runner; workflow callers + # may select any compatible ephemeral runner through inputs.runs-on. runs-on: ${{ inputs.runs-on || 'ubuntu-24.04-arm' }} steps: - name: Checkout code From 647ed0484ce9456fed2bcd4d05d42b4f7f22ea7d Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 18:45:36 -0500 Subject: [PATCH 08/10] ci: pin GitHub Actions to immutable commit SHAs Mutable action tags can change CI behavior without a reviewed dependency update. Pin every remote uses: reference to its current full commit SHA and keep the exact release version in a trailing comment. Co-Authored-By: Claude --- .github/workflows/build-container.yml | 22 +++++++++---------- .github/workflows/build-depends.yml | 24 ++++++++++----------- .github/workflows/build-src.yml | 20 ++++++++--------- .github/workflows/build.yml | 2 +- .github/workflows/cache-depends-sources.yml | 6 +++--- .github/workflows/clang-diff-format.yml | 4 +++- .github/workflows/guix-build-worker.yml | 16 +++++++------- .github/workflows/guix-build.yml | 8 +++---- .github/workflows/label-merge-conflicts.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/merge-check.yml | 6 +++--- .github/workflows/predict-conflicts.yml | 2 +- .github/workflows/release_docker_hub.yml | 14 ++++++------ .github/workflows/semantic-pull-request.yml | 2 +- .github/workflows/test-src.yml | 10 ++++----- 15 files changed, 71 insertions(+), 69 deletions(-) diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index f78f7be88456..ef41445cb8ae 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -42,7 +42,7 @@ jobs: digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.sha }} persist-credentials: false @@ -56,10 +56,10 @@ jobs: echo "repo=${REPO_NAME}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,7 +67,7 @@ jobs: - name: Build and push Docker image id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ inputs.context }} file: ${{ inputs.file }} @@ -91,7 +91,7 @@ jobs: digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.sha }} persist-credentials: false @@ -105,10 +105,10 @@ jobs: echo "repo=${REPO_NAME}" >> "$GITHUB_OUTPUT" - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -116,7 +116,7 @@ jobs: - name: Build and push Docker image id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ inputs.context }} file: ${{ inputs.file }} @@ -139,16 +139,16 @@ jobs: needs: [build-amd64, build-arm64] steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.sha }} persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/build-depends.yml b/.github/workflows/build-depends.yml index 1349bff2a89c..f2176df087c4 100644 --- a/.github/workflows/build-depends.yml +++ b/.github/workflows/build-depends.yml @@ -52,7 +52,7 @@ jobs: sdk-artifact: ${{ steps.prepare-sdks.outputs.artifact }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false @@ -91,7 +91,7 @@ jobs: - name: Check for cached depends id: cache-check - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/built/${{ steps.setup.outputs.HOST }} key: ${{ steps.setup.outputs.cache-key }} @@ -99,7 +99,7 @@ jobs: - name: Cache SDKs id: cache-sdk-check - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 if: inputs.build-target == 'mac' with: path: depends/SDKs @@ -117,7 +117,7 @@ jobs: - name: Save SDKs cache if: github.event_name == 'push' && steps.prepare-sdks.outputs.artifact != '' - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/SDKs key: depends-sdks-${{ hashFiles('depends/hosts/darwin.mk') }} @@ -126,7 +126,7 @@ jobs: # This originates in check-cache so both Build depends (when needed) # and Build source can use it even when a PR cannot save the cache. if: steps.prepare-sdks.outputs.artifact != '' - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ steps.prepare-sdks.outputs.artifact }} path: depends/SDKs @@ -153,14 +153,14 @@ jobs: options: --user root steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Restore depends sources - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/sources key: depends-sources-${{ hashFiles('depends/packages/*') }} @@ -168,7 +168,7 @@ jobs: - name: Restore SDKs cache id: sdk-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 if: inputs.build-target == 'mac' with: path: depends/SDKs @@ -177,7 +177,7 @@ jobs: - name: Download prepared SDKs if: inputs.build-target == 'mac' && steps.sdk-cache.outputs.cache-hit != 'true' && needs.check-cache.outputs.sdk-artifact != '' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ needs.check-cache.outputs.sdk-artifact }} path: depends/SDKs @@ -190,7 +190,7 @@ jobs: shell: bash - name: Restore cached depends - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/built/${{ needs.check-cache.outputs.host }} key: ${{ needs.check-cache.outputs.cache-key }} @@ -207,7 +207,7 @@ jobs: # June 2026), so only trusted events can save the persistent cache # for later read-only restores. if: github.event_name == 'push' - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/built/${{ needs.check-cache.outputs.host }} key: ${{ needs.check-cache.outputs.cache-key }} @@ -216,7 +216,7 @@ jobs: # Same-run handoff to build-src.yml for runs that cannot save the # cache. Uploaded on every fresh build (cache/save only warns when # the write is denied), so src jobs never depend on a save landing. - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: depends-built-${{ inputs.build-target }} path: depends/built/${{ needs.check-cache.outputs.host }} diff --git a/.github/workflows/build-src.yml b/.github/workflows/build-src.yml index 7cee1b57e2c7..27ae97c3c768 100644 --- a/.github/workflows/build-src.yml +++ b/.github/workflows/build-src.yml @@ -61,7 +61,7 @@ jobs: options: --user root steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 50 @@ -81,7 +81,7 @@ jobs: - name: Restore SDKs cache id: sdk-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 if: inputs.build-target == 'mac' with: path: | @@ -90,7 +90,7 @@ jobs: - name: Download prepared SDKs if: inputs.build-target == 'mac' && steps.sdk-cache.outputs.cache-hit != 'true' && inputs.sdk-artifact != '' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.sdk-artifact }} path: depends/SDKs @@ -104,7 +104,7 @@ jobs: - name: Restore depends cache id: depends-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/built/${{ inputs.depends-host }} key: ${{ inputs.depends-key }} @@ -115,7 +115,7 @@ jobs: # built depends arrive as an artifact instead of a cache entry. Also # covers trusted runs whose cache save was denied (save only warns). if: steps.depends-cache.outputs.cache-hit != 'true' && inputs.depends-artifact != '' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.depends-artifact }} path: depends/built/${{ inputs.depends-host }} @@ -135,7 +135,7 @@ jobs: shell: bash - name: Restore ccache cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | /cache/ccache @@ -162,7 +162,7 @@ jobs: if: | github.event_name == 'push' && github.ref_name == github.event.repository.default_branch - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | /cache/ccache @@ -170,7 +170,7 @@ jobs: - name: Restore ctcache cache if: inputs.build-target == 'linux64_multiprocess' - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | /cache/ctcache @@ -192,7 +192,7 @@ jobs: inputs.build-target == 'linux64_multiprocess' && github.event_name == 'push' && github.ref_name == github.event.repository.default_branch - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | /cache/ctcache @@ -216,7 +216,7 @@ jobs: echo "key=${BUNDLE_KEY}" >> "${GITHUB_OUTPUT}" - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: ${{ steps.bundle.outputs.key }} path: | diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 390b445b2e2c..87f598e88462 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -49,7 +49,7 @@ jobs: - name: Checkout code if: ${{ steps.skip-check.outputs.skip == 'false' }} - uses: actions/checkout@v4 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false diff --git a/.github/workflows/cache-depends-sources.yml b/.github/workflows/cache-depends-sources.yml index 469308181c42..ecb7890b7c59 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -23,7 +23,7 @@ jobs: runs-on: ${{ inputs.runs-on || 'ubuntu-24.04-arm' }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false @@ -31,7 +31,7 @@ jobs: - name: Check for cached sources id: cache-check - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/sources key: depends-sources-${{ hashFiles('depends/packages/*') }} @@ -46,7 +46,7 @@ jobs: if: | steps.cache-check.outputs.cache-hit != 'true' && (github.event_name == 'push' || github.event_name == 'schedule') - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/sources key: depends-sources-${{ hashFiles('depends/packages/*') }} diff --git a/.github/workflows/clang-diff-format.yml b/.github/workflows/clang-diff-format.yml index 25811ea647e8..16245e4f703b 100644 --- a/.github/workflows/clang-diff-format.yml +++ b/.github/workflows/clang-diff-format.yml @@ -9,7 +9,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false - name: Fetch git run: git fetch --no-tags -fu origin develop:develop - name: Run Clang-Format-Diff.py diff --git a/.github/workflows/guix-build-worker.yml b/.github/workflows/guix-build-worker.yml index 52238a540afa..07044fe1fba3 100644 --- a/.github/workflows/guix-build-worker.yml +++ b/.github/workflows/guix-build-worker.yml @@ -30,7 +30,7 @@ jobs: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -46,7 +46,7 @@ jobs: run: docker logout ghcr.io - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ inputs.source-ref }} path: dash @@ -56,7 +56,7 @@ jobs: - name: Restore depends sources id: depends-sources-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: dash/depends/sources key: depends-sources-${{ hashFiles('dash/depends/packages/*') }} @@ -65,7 +65,7 @@ jobs: - name: Restore Guix and depends cache id: guix-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | ${{ github.workspace }}/.cache @@ -97,14 +97,14 @@ jobs: - name: Save depends sources cache if: inputs.trusted && steps.depends-sources-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: dash/depends/sources key: depends-sources-${{ hashFiles('dash/depends/packages/*') }} - name: Save Guix and depends cache if: inputs.trusted && steps.guix-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | ${{ github.workspace }}/.cache @@ -121,7 +121,7 @@ jobs: HOSTS="${BUILD_TARGET}" ./dash/contrib/containers/guix/scripts/guix-check "${GITHUB_WORKSPACE}/dash" - name: Upload build artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: guix-artifacts-${{ matrix.build_target }} path: | @@ -129,6 +129,6 @@ jobs: - name: Attest build provenance if: inputs.trusted - uses: actions/attest@v4 + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0 with: subject-path: ${{ github.workspace }}/dash/guix-build*/output/${{ matrix.build_target }}/* diff --git a/.github/workflows/guix-build.yml b/.github/workflows/guix-build.yml index 35b597180497..e1505a3c83eb 100644 --- a/.github/workflows/guix-build.yml +++ b/.github/workflows/guix-build.yml @@ -25,7 +25,7 @@ jobs: path: ghcr.io/${{ steps.prepare.outputs.repo-name }}/dashcore-guix-builder:${{ steps.prepare.outputs.image-tag }} steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.sha }} path: dash @@ -33,7 +33,7 @@ jobs: persist-credentials: false - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Commit variables id: prepare @@ -48,14 +48,14 @@ jobs: echo "repo-name=${REPO_NAME}" >> "${GITHUB_OUTPUT}" - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build Docker image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ${{ github.workspace }}/dash build-args: | diff --git a/.github/workflows/label-merge-conflicts.yml b/.github/workflows/label-merge-conflicts.yml index b804b36c5138..bfebc22abc87 100644 --- a/.github/workflows/label-merge-conflicts.yml +++ b/.github/workflows/label-merge-conflicts.yml @@ -28,7 +28,7 @@ jobs: runs-on: ubuntu-latest steps: - name: check if prs are dirty - uses: eps1lon/actions-label-merge-conflict@v3.1.0 + uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0 with: dirtyLabel: "needs rebase" repoToken: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f7a28aaf9536..c70542290716 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -28,7 +28,7 @@ jobs: options: --user root steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 50 diff --git a/.github/workflows/merge-check.yml b/.github/workflows/merge-check.yml index c7fce44a329c..fb0d20fb316a 100644 --- a/.github/workflows/merge-check.yml +++ b/.github/workflows/merge-check.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: 0 @@ -45,7 +45,7 @@ jobs: fi - name: add labels - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 if: failure() && github.event.pull_request with: script: | @@ -57,7 +57,7 @@ jobs: }); - name: comment - uses: mshick/add-pr-comment@v3 + uses: mshick/add-pr-comment@ec328af66588ab8f77cdeb2c264f14aba45bbf59 # v3.12.0 if: failure() with: message: | diff --git a/.github/workflows/predict-conflicts.yml b/.github/workflows/predict-conflicts.yml index 189a563ed652..80f002f1cd0b 100644 --- a/.github/workflows/predict-conflicts.yml +++ b/.github/workflows/predict-conflicts.yml @@ -27,7 +27,7 @@ jobs: cancel-in-progress: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: persist-credentials: false - name: Validate potential conflicts and update advisory comments diff --git a/.github/workflows/release_docker_hub.yml b/.github/workflows/release_docker_hub.yml index 707be942826a..191d6d0f2235 100644 --- a/.github/workflows/release_docker_hub.yml +++ b/.github/workflows/release_docker_hub.yml @@ -10,18 +10,18 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 with: image: tonistiigi/binfmt:qemu-v7.0.0-28 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} @@ -33,7 +33,7 @@ jobs: echo "build_tag=${TAG#v}" >> $GITHUB_OUTPUT - name: Set suffix - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 id: suffix with: result-encoding: string @@ -49,7 +49,7 @@ jobs: - name: Set Docker tags and labels id: docker_meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: dashpay/dashd tags: | @@ -62,7 +62,7 @@ jobs: - name: Build and push id: docker_build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: ./contrib/containers/deploy file: ./contrib/containers/deploy/Dockerfile.GitHubActions.Release diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 191a34f37381..0e609b83716d 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -12,7 +12,7 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/test-src.yml b/.github/workflows/test-src.yml index 9b5742bf42a5..4608b27bcf4e 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -40,7 +40,7 @@ jobs: options: --user root steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: ref: ${{ github.event.pull_request.head.sha || github.sha }} fetch-depth: 1 @@ -48,13 +48,13 @@ jobs: allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }} - name: Download build artifacts - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.bundle-key }} - name: Restore releases cache id: releases-cache - uses: actions/cache/restore@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 if: inputs.build-target == 'linux64' with: path: releases @@ -77,7 +77,7 @@ jobs: github.event_name == 'push' && inputs.build-target == 'linux64' && steps.releases-cache.outputs.cache-hit != 'true' - uses: actions/cache/save@v5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: releases key: releases-${{ hashFiles('ci/test/00_setup_env_native_qt5.sh', 'test/get_previous_releases.py') }} @@ -93,7 +93,7 @@ jobs: shell: bash - name: Upload test logs - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: | success() || (failure() && steps.test.outcome == 'failure') && steps.bundle.outputs.upload-logs == 'true' From bf057f51a6f2c871a5d1c121340e10209ed90ed8 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 18:45:40 -0500 Subject: [PATCH 09/10] ci: enable Dependabot for GitHub Actions updates Propose action pin updates through reviewable Dependabot PRs so future checkout and third-party action releases no longer land by silent tag moves. Co-Authored-By: Claude --- .github/dependabot.yml | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..4c1e1b47f7ee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +# Keep third-party GitHub Actions on reviewed, immutable updates. +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" From c38801d3e9279e6912cc49673ae1bfd587faf331 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Mon, 20 Jul 2026 18:45:40 -0500 Subject: [PATCH 10/10] lint: require immutable GitHub Action pins Fail CI when remote workflow uses: refs are not full 40-hex SHAs or lack an exact release-version comment, so mutable tags cannot reappear. Co-Authored-By: Claude --- test/lint/lint-ci-action-pins.py | 310 +++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100755 test/lint/lint-ci-action-pins.py diff --git a/test/lint/lint-ci-action-pins.py b/test/lint/lint-ci-action-pins.py new file mode 100755 index 000000000000..a6979a839dcf --- /dev/null +++ b/test/lint/lint-ci-action-pins.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Dash Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. + +"""Require remote GitHub Actions to be pinned to immutable commit SHAs. + +Parses workflow YAML so equivalent forms (quoted keys, flow mappings, nested +jobs/steps) cannot bypass the pin check. Version comments are verified from +source text because YAML discards comments. +""" + +from __future__ import annotations + +import re +import sys +import tempfile +from pathlib import Path +from typing import Any, Iterable + +import yaml + + +ROOT = Path(__file__).resolve().parents[2] +GITHUB_DIR = ROOT / ".github" + +# owner/name[/path...]@ref +REMOTE_ACTION_RE = re.compile( + r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@(?P[^\s#]+)$" +) +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +VERSION_COMMENT_RE = re.compile(r"^v?\d+(?:\.\d+){0,3}$") +# Source-text match for a remote pin plus an exact release-version comment. +PIN_WITH_VERSION_RE = re.compile( + r"(?P[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@[0-9a-f]{40})" + r"[^\n#]*#\s*(?P\S+)" +) + + +def normalize_gha(node: Any) -> Any: + """Repair YAML 1.1 bool coercion of the GitHub Actions `on` key. + + PyYAML's SafeLoader turns the bare key `on:` into boolean True. Values such + as `true`/`false` must remain real booleans for permission and checkout + checks elsewhere, so only mapping keys are rewritten. + """ + if isinstance(node, dict): + fixed = {} + for key, value in node.items(): + if key is True: + key = "on" + elif key is False: + key = "off" + fixed[key] = normalize_gha(value) + return fixed + if isinstance(node, list): + return [normalize_gha(item) for item in node] + return node + + +def load_yaml(text: str) -> Any: + return normalize_gha(yaml.safe_load(text)) + + +def iter_uses(node: Any) -> Iterable[str]: + """Yield every mapping value whose key is the string 'uses'.""" + if isinstance(node, dict): + for key, value in node.items(): + if key == "uses": + if isinstance(value, str): + yield value + else: + yield f"" + else: + yield from iter_uses(value) + elif isinstance(node, list): + for item in node: + yield from iter_uses(item) + + +def versioned_pins(text: str) -> dict[str, str]: + """Map full remote pin refs to their trailing version-comment token.""" + found: dict[str, str] = {} + for match in PIN_WITH_VERSION_RE.finditer(text): + found[match.group("ref")] = match.group("version") + return found + + +def check_workflow(path: Path, rel: Path, text: str, data: Any) -> list[str]: + errors: list[str] = [] + if data is None: + # Empty YAML documents have nothing to pin. + return errors + + pins = versioned_pins(text) + for ref in iter_uses(data): + if ref.startswith("./"): + continue + if ref.startswith("docker://"): + errors.append( + f"{rel}: unsupported docker:// action {ref!r}; " + f"pin remote GitHub Actions with owner/name@<40-hex-sha>" + ) + continue + + remote = REMOTE_ACTION_RE.match(ref) + if remote is None: + errors.append( + f"{rel}: unsupported `uses:` reference {ref!r} " + f"(expected owner/name[/...]@<40-hex-sha> or local ./ path)" + ) + continue + + pin = remote.group("ref") + if not SHA_RE.fullmatch(pin): + errors.append( + f"{rel}: mutable GitHub Action ref {ref!r}; " + f"pin to a full 40-character commit SHA" + ) + continue + + version = pins.get(ref) + if not version or not VERSION_COMMENT_RE.fullmatch(version): + errors.append( + f"{rel}: pinned action {ref!r} must include an exact " + f"release-version comment (for example `# v6.1.0`)" + ) + return errors + + +def check_github_dir(github_dir: Path, root: Path | None = None) -> list[str]: + root = root or github_dir.parent + errors: list[str] = [] + workflow_files = sorted(github_dir.rglob("*.yml")) + sorted(github_dir.rglob("*.yaml")) + if not workflow_files: + return [f"ERROR: no workflow YAML files found under {github_dir}"] + + for path in workflow_files: + rel = path.relative_to(root) + text = path.read_text(encoding="utf-8") + try: + data = load_yaml(text) + except yaml.YAMLError as exc: + errors.append(f"{rel}: YAML parse error: {exc}") + continue + errors.extend(check_workflow(path, rel, text, data)) + return errors + + +def _write(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def self_test() -> None: + """Negative and positive fixtures for alternate YAML uses: forms.""" + good_sha = "d23441a48e516b6c34aea4fa41551a30e30af803" + good_ref = f"actions/checkout@{good_sha}" + + cases: list[tuple[str, str, bool, str]] = [ + ( + "quoted-key-mutable", + f'jobs:\n x:\n steps:\n - "uses": actions/checkout@v6\n', + False, + "mutable", + ), + ( + "single-quoted-key-mutable", + "jobs:\n x:\n steps:\n - 'uses': actions/cache@v5\n", + False, + "mutable", + ), + ( + "flow-mapping-mutable", + "jobs:\n x:\n steps:\n - {uses: actions/checkout@v6}\n", + False, + "mutable", + ), + ( + "nested-job-mutable", + ( + "on: push\n" + "jobs:\n" + " build:\n" + " steps:\n" + " - name: hi\n" + " uses: actions/setup-python@v5\n" + ), + False, + "mutable", + ), + ( + "good-pin-with-version", + ( + "jobs:\n" + " x:\n" + " steps:\n" + f" - uses: {good_ref} # v6.1.0\n" + ), + True, + "", + ), + ( + "good-pin-quoted-key", + ( + "jobs:\n" + " x:\n" + " steps:\n" + f' - "uses": {good_ref} # v6.1.0\n' + ), + True, + "", + ), + ( + "flow-mapping-good", + ( + "jobs:\n" + " x:\n" + " steps:\n" + f" - {{uses: {good_ref}}} # v6.1.0\n" + ), + True, + "", + ), + ( + "missing-version-comment", + ( + "jobs:\n" + " x:\n" + " steps:\n" + f" - uses: {good_ref}\n" + ), + False, + "release-version comment", + ), + ( + "local-action-ok", + "jobs:\n x:\n steps:\n - uses: ./.github/actions/local\n", + True, + "", + ), + ( + "inline-comment-mutable", + ( + "jobs:\n" + " x:\n" + " steps:\n" + " - uses: actions/checkout@v6 # not a pin\n" + ), + False, + "mutable", + ), + ( + "docker-action-rejected", + "jobs:\n x:\n steps:\n - uses: docker://alpine:3.19\n", + False, + "docker://", + ), + ] + + for name, body, expect_ok, needle in cases: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write(root / ".github" / "workflows" / f"{name}.yml", body) + # Keep `on` un-coerced in fixtures that use it. + errors = check_github_dir(root / ".github", root=root) + ok = not errors + if ok != expect_ok: + raise AssertionError( + f"self-test {name}: expected ok={expect_ok}, errors={errors}" + ) + if not expect_ok and needle and not any(needle in e for e in errors): + raise AssertionError( + f"self-test {name}: expected error containing {needle!r}, got {errors}" + ) + + # Malformed YAML must fail closed. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + _write(root / ".github" / "workflows" / "broken.yml", "jobs: [\n") + errors = check_github_dir(root / ".github", root=root) + if not errors or "YAML parse error" not in errors[0]: + raise AssertionError(f"self-test broken-yaml: expected parse error, got {errors}") + + +def main() -> int: + try: + self_test() + except AssertionError as exc: + print(f"ERROR: lint-ci-action-pins self-test failed: {exc}", file=sys.stderr) + return 1 + + errors = check_github_dir(GITHUB_DIR, root=ROOT) + # Distinguish missing-files from pin violations. + if len(errors) == 1 and errors[0].startswith("ERROR: no workflow"): + print(errors[0], file=sys.stderr) + return 1 + + if errors: + print("ERROR: GitHub Actions must be pinned to immutable SHAs with version comments:") + for error in errors: + print(f" {error}") + return 1 + + print("OK: all remote GitHub Actions are pinned to immutable SHAs") + return 0 + + +if __name__ == "__main__": + sys.exit(main())