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" diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index 078a3e84a5ec..ef41445cb8ae 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -31,17 +31,20 @@ 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 }} digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.sha }} persist-credentials: false - name: Prepare variables @@ -53,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 }} @@ -64,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 }} @@ -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 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + ref: ${{ github.sha }} persist-credentials: false - name: Prepare variables @@ -99,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 }} @@ -110,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 }} @@ -125,21 +131,24 @@ 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 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - ref: ${{ github.event.pull_request.head.sha }} - allow-unsafe-pr-checkout: true + 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 07b89e270203..f2176df087c4 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 }} @@ -49,11 +52,11 @@ 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 }} - 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 @@ -66,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}" @@ -85,7 +91,7 @@ jobs: - name: Check for cached depends id: cache-check - uses: actions/cache@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/built/${{ steps.setup.outputs.HOST }} key: ${{ steps.setup.outputs.cache-key }} @@ -93,7 +99,7 @@ jobs: - name: Cache SDKs id: cache-sdk-check - uses: actions/cache@v5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 if: inputs.build-target == 'mac' with: path: depends/SDKs @@ -109,11 +115,18 @@ 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@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + 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. 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 @@ -126,21 +139,28 @@ jobs: needs: [check-cache] if: needs.check-cache.outputs.cache-hit != 'true' runs-on: ${{ inputs.runs-on }} + 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 - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 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 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/sources key: depends-sources-${{ hashFiles('depends/packages/*') }} @@ -148,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 @@ -157,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 @@ -170,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 }} @@ -186,8 +206,8 @@ 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' - uses: actions/cache/save@v5 + if: github.event_name == 'push' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: depends/built/${{ needs.check-cache.outputs.host }} key: ${{ needs.check-cache.outputs.cache-key }} @@ -196,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 8ea9ba443378..27ae97c3c768 100644 --- a/.github/workflows/build-src.yml +++ b/.github/workflows/build-src.yml @@ -47,19 +47,26 @@ jobs: build-src: name: Build source runs-on: ${{ inputs.runs-on }} + 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 - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 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 @@ -74,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: | @@ -83,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 @@ -97,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 }} @@ -108,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 }} @@ -128,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 @@ -155,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 @@ -163,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 @@ -185,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 @@ -209,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 e8c993c6eeec..87f598e88462 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ on: permissions: actions: read contents: read - packages: write + packages: read concurrency: group: | @@ -49,7 +49,9 @@ jobs: - name: Checkout code if: ${{ steps.skip-check.outputs.skip == 'false' }} - uses: actions/checkout@v6 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + 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,42 @@ 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: + 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 + # 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 + + 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 +152,35 @@ 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: + 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 + # 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 + 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..ecb7890b7c59 100644 --- a/.github/workflows/cache-depends-sources.yml +++ b/.github/workflows/cache-depends-sources.yml @@ -15,20 +15,23 @@ on: jobs: cache-sources: name: Cache depends sources - # Intentionally keep scheduled cache warming on GitHub-hosted ARM runners. - # Blacksmith caches are expected to persist long enough without a warmup cron. + permissions: + actions: read + contents: read + # 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 - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 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@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 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@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 new file mode 100644 index 000000000000..07044fe1fba3 --- /dev/null +++ b/.github/workflows/guix-build-worker.yml @@ -0,0 +1,134 @@ +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: Login to GitHub Container Registry + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + 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@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + 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@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + 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@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + 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@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@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + 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@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 98ecc74afe9e..e1505a3c83eb 100644 --- a/.github/workflows/guix-build.yml +++ b/.github/workflows/guix-build.yml @@ -1,9 +1,8 @@ name: Guix Build permissions: - packages: write - id-token: write - attestations: write + contents: read + packages: read on: pull_request_target: @@ -18,44 +17,45 @@ 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 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 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 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - 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 + 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: | @@ -71,86 +71,48 @@ 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 - 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 + - name: Select trusted Guix builder + id: resolve + env: + BASE_BRANCH: ${{ github.base_ref }} + REPOSITORY: ${{ github.repository }} 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 }}/* + # 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 + permissions: + actions: read + contents: read + packages: 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 + packages: 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/.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 b936e2657c78..c70542290716 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,17 +16,24 @@ jobs: lint: name: Run linters runs-on: ${{ inputs.runs-on }} + 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 - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 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/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 f27751be60a2..4608b27bcf4e 100644 --- a/.github/workflows/test-src.yml +++ b/.github/workflows/test-src.yml @@ -28,29 +28,36 @@ jobs: test-src: name: Test source runs-on: ${{ inputs.runs-on }} + 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 - uses: actions/checkout@v6 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 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 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.bundle-key }} - - name: Manage releases cache - uses: actions/cache@v5 + - name: Restore releases cache + id: releases-cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 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 +72,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@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + 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') @@ -76,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' 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", 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-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()) diff --git a/test/lint/lint-ci-workflow-security.py b/test/lint/lint-ci-workflow-security.py new file mode 100755 index 000000000000..e69315f969e6 --- /dev/null +++ b/test/lint/lint-ci-workflow-security.py @@ -0,0 +1,501 @@ +#!/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. + +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 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 load_workflow(path: Path) -> Any: + return normalize_gha(yaml.safe_load(path.read_text(encoding="utf-8"))) + + +def workflow_data(name: str, workflows: dict[str, Any]) -> Any: + assert name in workflows, f"missing workflow {name}" + return workflows[name] + + +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 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)" + ) + + +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" + ) + 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" + ) + + 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" + ) + if "allow-unsafe-pr-checkout" not in with_block: + raise AssertionError( + f"{name} job {job_name}: must explicitly acknowledge the isolated " + f"PR checkout" + ) + + +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(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(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_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", + "build-src.yml", + "cache-depends-sources.yml", + "lint.yml", + "test-src.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") + # 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__": + 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")