From 8b227f70089f1a71702e282e05c684ed720da78d Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Sat, 10 Oct 2026 02:16:59 +0200 Subject: [PATCH 01/55] Scan a clean image build and keep the download progress out of the log A scan judges what a clean build produces today. A cached apt-get upgrade layer or a stale base image keeps a package version that is already fixed, on a runner whose Docker daemon outlives the job. Trivy gets --no-progress since a CI log is not a terminal. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01BurkSVvSTdqzabro1Fs1nk --- .github/workflows/reusable-container-scan.yml | 4 +++- actions/trivy/scan/action.yml | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index 33405b3..7a654b5 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -76,8 +76,10 @@ jobs: repository: devpro/github-workflow-parts ref: ${{ inputs.workflow-parts-version }} path: workflow-parts + # A scan judges what a clean build produces today. + # A cached `apt-get upgrade` layer or a stale base image keeps an already fixed package version, on a runner whose Docker daemon outlives the job. - name: Build container image - run: docker build . --file ${{ inputs.image-definition }} --tag ${{ env.IMAGE_REF }} + run: docker build . --pull --no-cache --file ${{ inputs.image-definition }} --tag ${{ env.IMAGE_REF }} shell: bash # deactivated 2026-02-22: "Build container for action use... process "/bin/sh -c zypper in -y jq docker && zypper clean" did not complete successfully: exit code: 8" # - name: Scan container image with NeuVector diff --git a/actions/trivy/scan/action.yml b/actions/trivy/scan/action.yml index fd0eb3d..12b63dc 100644 --- a/actions/trivy/scan/action.yml +++ b/actions/trivy/scan/action.yml @@ -90,7 +90,8 @@ runs: IGNORE_UNFIXED: ${{ inputs.ignore-unfixed }} PKG_TYPES: ${{ inputs.pkg-types }} run: | - args=("$SCAN_TYPE" --format "$FORMAT" --severity "$SEVERITY" --exit-code "$EXIT_CODE") + # --no-progress: a CI log is not a terminal, and every redraw of the database download bar becomes log text + args=("$SCAN_TYPE" --no-progress --format "$FORMAT" --severity "$SEVERITY" --exit-code "$EXIT_CODE") if [ -n "$OUTPUT_FILE" ]; then args+=(--output "$OUTPUT_FILE") From b0874542b7775a5dff2edb6bd3f7aa4861acda23 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 12 Oct 2026 17:38:59 +0200 Subject: [PATCH 02/55] Add composite replacements for setup-helm and chart-testing-action Co-Authored-By: Claude Sonnet 5.5 --- README.md | 5 ++ actions/chart-testing/setup/action.yml | 63 ++++++++++++++++++++++++++ actions/helm/setup/action.yml | 44 ++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 actions/chart-testing/setup/action.yml create mode 100644 actions/helm/setup/action.yml diff --git a/README.md b/README.md index cf9a308..1ce5e2b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ Markup (Markdown, YAML): - [Ensure the quality of your content](.github/workflows/reusable-markup-lint.yml) +Helm: + +- [Setup Helm](actions/helm/setup/action.yml) +- [Setup chart-testing](actions/chart-testing/setup/action.yml) + Terraform: - [Deploy your environment](.github/workflows/reusable-terraform-deployment.yml) diff --git a/actions/chart-testing/setup/action.yml b/actions/chart-testing/setup/action.yml new file mode 100644 index 0000000..7873866 --- /dev/null +++ b/actions/chart-testing/setup/action.yml @@ -0,0 +1,63 @@ +name: Setup chart-testing +description: | + Installs the chart-testing CLI (ct) by downloading the official release and verifying its SHA256 checksum, with the yamllint and yamale it calls + (replacement for helm/chart-testing-action, to reduce third-party GitHub Action supply-chain risk). + Helm and git must be installed beforehand, for example with the Setup Helm action. + Linux runners only (for example ubuntu-latest). + +inputs: + chart-testing-version: + description: Version of chart-testing to install (check latest from https://github.com/helm/chart-testing/releases) + required: false + default: "v3.15.0" + yamllint-version: + description: Version of yamllint to install + required: false + default: "1.37.1" + yamale-version: + description: Version of yamale to install + required: false + default: "6.0.0" + +runs: + using: "composite" + steps: + - name: Install chart-testing + shell: bash + env: + CT_VERSION: ${{ inputs.chart-testing-version }} + YAMLLINT_VERSION: ${{ inputs.yamllint-version }} + YAMALE_VERSION: ${{ inputs.yamale-version }} + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + CT_TAR="chart-testing_${CT_VERSION#v}_linux_amd64.tar.gz" + BASE_URL="https://github.com/helm/chart-testing/releases/download/${CT_VERSION}" + + echo "Downloading chart-testing binary and checksums..." + curl -sSL -O "${BASE_URL}/${CT_TAR}" + curl -sSL -O "${BASE_URL}/checksums.txt" + + echo "Verifying checksum..." + if grep " ${CT_TAR}$" checksums.txt | sha256sum --check --status; then + echo "Verification successful!" + tar -xzf "${CT_TAR}" ct etc + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + mv ct "${RUNNER_TEMP}/bin/ct" + # The schema and the lint rules are looked up by ct in $HOME/.ct when no flag names them. + mkdir -p "${HOME}/.ct" + cp etc/chart_schema.yaml etc/lintconf.yaml "${HOME}/.ct/" + # Kept in a virtual environment of their own, since the system Python may refuse a global install. + python3 -m venv "${RUNNER_TEMP}/ct-venv" + "${RUNNER_TEMP}/ct-venv/bin/pip" install --quiet "yamllint==${YAMLLINT_VERSION}" "yamale==${YAMALE_VERSION}" + ln -sf "${RUNNER_TEMP}/ct-venv/bin/yamllint" "${RUNNER_TEMP}/bin/yamllint" + ln -sf "${RUNNER_TEMP}/ct-venv/bin/yamale" "${RUNNER_TEMP}/bin/yamale" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + export PATH="${RUNNER_TEMP}/bin:$PATH" + rm -rf etc "${CT_TAR}" checksums.txt + ct version + else + echo "ERROR: Checksum verification failed!" >&2 + exit 1 + fi diff --git a/actions/helm/setup/action.yml b/actions/helm/setup/action.yml new file mode 100644 index 0000000..d8c5f79 --- /dev/null +++ b/actions/helm/setup/action.yml @@ -0,0 +1,44 @@ +name: Setup Helm +description: | + Installs the Helm CLI by downloading the official binary from get.helm.sh and verifying its SHA256 checksum + (replacement for azure/setup-helm, to reduce third-party GitHub Action supply-chain risk). + Linux runners only (for example ubuntu-latest). + +inputs: + helm-version: + description: Version of Helm to install (check latest from https://github.com/helm/helm/releases) + required: false + default: "v4.3.0" + +runs: + using: "composite" + steps: + - name: Install Helm + shell: bash + env: + HELM_VERSION: ${{ inputs.helm-version }} + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + HELM_TAR="helm-${HELM_VERSION}-linux-amd64.tar.gz" + BASE_URL="https://get.helm.sh" + + echo "Downloading Helm binary and checksum..." + curl -sSL -O "${BASE_URL}/${HELM_TAR}" + curl -sSL -O "${BASE_URL}/${HELM_TAR}.sha256sum" + + echo "Verifying checksum..." + if sha256sum --check --status "${HELM_TAR}.sha256sum"; then + echo "Verification successful!" + tar -xzf "${HELM_TAR}" linux-amd64/helm + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + mv linux-amd64/helm "${RUNNER_TEMP}/bin/helm" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + export PATH="${RUNNER_TEMP}/bin:$PATH" + rm -rf linux-amd64 "${HELM_TAR}" "${HELM_TAR}.sha256sum" + helm version + else + echo "ERROR: Checksum verification failed!" >&2 + exit 1 + fi From 2e00ae9f2b24b9a45001114558b0e1cac668aa13 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 12 Oct 2026 18:03:22 +0200 Subject: [PATCH 03/55] Mention IstarCI in the contributing guide --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ef7241e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,6 @@ +# Contributing + +## Run the pipeline locally + +[IstarCI](https://github.com/devpro/istarci) runs the GitHub Actions pipeline in Docker before the push. +`istarci add` and `istarci install-hook` set it up for this repository. From 18e8119f6355358486ebaba80a2137fd4b2202ef Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 12 Oct 2026 18:04:00 +0200 Subject: [PATCH 04/55] Test the Helm and chart-testing setup actions Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e4956e..d64daa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,34 @@ jobs: tflint --version command -v tflint + test-helm-setup: + name: "Test / Helm setup action" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v7 + - name: Setup Helm + uses: ./actions/helm/setup + - name: Verify helm is installed + run: | + helm version + command -v helm + + test-chart-testing-setup: + name: "Test / chart-testing setup action" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v7 + - name: Setup Helm + uses: ./actions/helm/setup + - name: Setup chart-testing + uses: ./actions/chart-testing/setup + - name: Verify chart-testing is installed + run: | + ct version + command -v ct yamllint yamale + test-syft-generate-sbom: name: "Test / Syft generate-sbom action" runs-on: ubuntu-latest From 4f6629988c890557a896f2bbcd43bcb623bd51fb Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 12 Oct 2026 18:20:44 +0200 Subject: [PATCH 05/55] Test the actions in the checkout, and pass --no-progress to image scans only Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 5 ++--- .github/workflows/reusable-terraform-quality.yml | 9 ++++++++- actions/trivy/scan/action.yml | 7 ++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d64daa3..5cb1a4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,5 @@ jobs: uses: ./.github/workflows/reusable-terraform-quality.yml with: working-directory: test/fixtures/terraform - # explicit override: for this same-repo local "./" call, github.workflow_sha's behavior is unverified, - # so pin to a ref we know is fetchable (branch on push, PR merge ref on PRs) - workflow-parts-version: ${{ github.ref }} + # empty: the actions under test are the ones in the checkout, not a copy fetched from GitHub that may not hold the change yet + workflow-parts-version: "" diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index 4e41fbc..d99b262 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -19,7 +19,7 @@ on: required: false default: "ubuntu-latest" workflow-parts-version: - description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself." + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Empty to use the actions of the repository being checked out, which is what this repository's own CI does." type: string required: false default: "main" @@ -34,7 +34,14 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v7 + - name: Use the workflow parts of this repository + if: inputs.workflow-parts-version == '' + shell: bash + run: | + mkdir -p "$GITHUB_WORKSPACE/workflow-parts" + cp -r "$GITHUB_WORKSPACE/actions" "$GITHUB_WORKSPACE/workflow-parts/" - name: Checkout workflow parts + if: inputs.workflow-parts-version != '' uses: actions/checkout@v7 with: repository: devpro/github-workflow-parts diff --git a/actions/trivy/scan/action.yml b/actions/trivy/scan/action.yml index 12b63dc..c22edc6 100644 --- a/actions/trivy/scan/action.yml +++ b/actions/trivy/scan/action.yml @@ -90,15 +90,16 @@ runs: IGNORE_UNFIXED: ${{ inputs.ignore-unfixed }} PKG_TYPES: ${{ inputs.pkg-types }} run: | - # --no-progress: a CI log is not a terminal, and every redraw of the database download bar becomes log text - args=("$SCAN_TYPE" --no-progress --format "$FORMAT" --severity "$SEVERITY" --exit-code "$EXIT_CODE") + args=("$SCAN_TYPE" --format "$FORMAT" --severity "$SEVERITY" --exit-code "$EXIT_CODE") if [ -n "$OUTPUT_FILE" ]; then args+=(--output "$OUTPUT_FILE") fi - # ignore-unfixed and pkg-types only make sense for vulnerability (image) scans + # no-progress, ignore-unfixed and pkg-types only exist for vulnerability (image) scans if [ "$SCAN_TYPE" = "image" ]; then + # A CI log is not a terminal, and every redraw of the database download bar becomes log text. + args+=(--no-progress) if [ "$IGNORE_UNFIXED" = "true" ]; then args+=(--ignore-unfixed) fi From b690506b37b54f9c5cd38fcfd0d879c904248aba Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 12 Oct 2026 18:21:31 +0200 Subject: [PATCH 06/55] Use the actions of the checkout when the caller is this repository Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 2 -- .github/workflows/reusable-terraform-quality.yml | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cb1a4f..15ec3a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,5 +119,3 @@ jobs: uses: ./.github/workflows/reusable-terraform-quality.yml with: working-directory: test/fixtures/terraform - # empty: the actions under test are the ones in the checkout, not a copy fetched from GitHub that may not hold the change yet - workflow-parts-version: "" diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index d99b262..895f765 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -19,7 +19,7 @@ on: required: false default: "ubuntu-latest" workflow-parts-version: - description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Empty to use the actions of the repository being checked out, which is what this repository's own CI does." + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." type: string required: false default: "main" @@ -35,13 +35,13 @@ jobs: - name: Clone repository uses: actions/checkout@v7 - name: Use the workflow parts of this repository - if: inputs.workflow-parts-version == '' + if: github.repository == 'devpro/github-workflow-parts' shell: bash run: | mkdir -p "$GITHUB_WORKSPACE/workflow-parts" cp -r "$GITHUB_WORKSPACE/actions" "$GITHUB_WORKSPACE/workflow-parts/" - name: Checkout workflow parts - if: inputs.workflow-parts-version != '' + if: github.repository != 'devpro/github-workflow-parts' uses: actions/checkout@v7 with: repository: devpro/github-workflow-parts From defde40cfcaa2dc67c1cc47a27d20605f76d74b0 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 12 Oct 2026 18:23:03 +0200 Subject: [PATCH 07/55] Run the pipeline with the step conditions kept Co-Authored-By: Claude Sonnet 5.5 From cb6e34481b30fe74b946b5ab760bc1c4886fdbbb Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 13 Oct 2026 15:58:58 +0200 Subject: [PATCH 08/55] Start MongoDB from its archives without sudo, and let the .NET quality workflow start it Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 ++++ .github/workflows/reusable-dotnet-quality.yml | 8 +++ actions/mongodb/start/action.yml | 61 +++++++++++++++---- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15ec3a6..4bec131 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,17 @@ jobs: ct version command -v ct yamllint yamale + test-mongodb-start: + name: "Test / MongoDB start action" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v7 + - name: Start MongoDB + uses: ./actions/mongodb/start + - name: Verify MongoDB answers + run: mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' mongodb://127.0.0.1:27017 | grep -qx 1 + test-syft-generate-sbom: name: "Test / Syft generate-sbom action" runs-on: ubuntu-latest diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 786b27d..0e29425 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -33,6 +33,11 @@ on: type: boolean required: false default: false + mongodb-enabled: + description: Start a local MongoDB server on 127.0.0.1:27017, with mongosh on the path, before build & test + type: boolean + required: false + default: false job-name: description: Job name type: string @@ -146,6 +151,9 @@ jobs: uses: ./workflow-parts/actions/dotnet/install-lint-restore with: dotnet-version: ${{ inputs.dotnet-version }} + - name: Start MongoDB + if: ${{ inputs.mongodb-enabled }} + uses: ./workflow-parts/actions/mongodb/start - name: Run optional custom commands if: ${{ inputs.custom-commands != '' }} run: | diff --git a/actions/mongodb/start/action.yml b/actions/mongodb/start/action.yml index 78d995d..dae09de 100644 --- a/actions/mongodb/start/action.yml +++ b/actions/mongodb/start/action.yml @@ -1,24 +1,61 @@ name: Start MongoDB -description: Starts a local MongoDB database +description: | + Starts a local MongoDB server on 127.0.0.1:27017, with mongosh on the path, + from the official archives with their SHA256 checksums verified rather than the apt packages, + so neither root nor sudo is needed, on a hosted runner or in a container. + Linux x64 runners on Ubuntu 24.04 only (for example ubuntu-latest). + +inputs: + mongodb-version: + description: Version of the MongoDB server (check latest from https://www.mongodb.com/try/download/community) + required: false + default: "8.2.12" + mongosh-version: + description: Version of mongosh (check latest from https://github.com/mongodb-js/mongosh/releases) + required: false + default: "2.13.0" + mongosh-sha256: + description: SHA256 of the mongosh archive, given with any other mongosh-version, since its release publishes no checksum file + required: false + default: "b2089e67641a28aa621476c4d62c69f66b9a41484baba24d8a8b1f5f96e92d0b" runs: using: "composite" steps: - # ref. https://www.mongodb.com/docs/manual/tutorial/install-mongodb-on-ubuntu/ - name: Install & start MongoDB + shell: bash + env: + MONGODB_ARCHIVE: mongodb-linux-x86_64-ubuntu2404-${{ inputs.mongodb-version }}.tgz + MONGOSH_VERSION: ${{ inputs.mongosh-version }} + MONGOSH_ARCHIVE: mongosh-${{ inputs.mongosh-version }}-linux-x64.tgz + MONGOSH_SHA256: ${{ inputs.mongosh-sha256 }} run: | - curl -fsSL https://www.mongodb.org/static/pgp/server-8.0.asc | \ - sudo gpg -o /usr/share/keyrings/mongodb-server-8.0.gpg \ - --dearmor - echo "deb [ arch=amd64,arm64 signed-by=/usr/share/keyrings/mongodb-server-8.0.gpg ] https://repo.mongodb.org/apt/ubuntu noble/mongodb-org/8.2 multiverse" | - sudo tee /etc/apt/sources.list.d/mongodb-org-8.2.list - sudo apt-get update - sudo apt-get install -y mongodb-org - sudo systemctl start mongod + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like an archive. + cd "$(mktemp -d)" + + echo "Downloading MongoDB server and mongosh..." + curl -fsSL -O "https://fastdl.mongodb.org/linux/${MONGODB_ARCHIVE}" + curl -fsSL -O "https://fastdl.mongodb.org/linux/${MONGODB_ARCHIVE}.sha256" + curl -fsSL -O "https://github.com/mongodb-js/mongosh/releases/download/v${MONGOSH_VERSION}/${MONGOSH_ARCHIVE}" + + echo "Verifying checksums..." + sha256sum --check --status "${MONGODB_ARCHIVE}.sha256" + echo "${MONGOSH_SHA256} ${MONGOSH_ARCHIVE}" | sha256sum --check --status + + # Installed where the job owns the directory, so neither root nor sudo is needed. + mkdir -p "${RUNNER_TEMP}/bin" "${RUNNER_TEMP}/mongodb/data" + tar xzf "${MONGODB_ARCHIVE}" --strip-components=2 -C "${RUNNER_TEMP}/bin" --wildcards "*/bin/mongod" + tar xzf "${MONGOSH_ARCHIVE}" --strip-components=2 -C "${RUNNER_TEMP}/bin" --wildcards "*/bin/mongosh" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + export PATH="${RUNNER_TEMP}/bin:$PATH" + rm -f "${MONGODB_ARCHIVE}" "${MONGODB_ARCHIVE}.sha256" "${MONGOSH_ARCHIVE}" + + # Forked rather than started by systemd, which a container has no instance of. + mongod --dbpath "${RUNNER_TEMP}/mongodb/data" --bind_ip 127.0.0.1 --fork --logpath "${RUNNER_TEMP}/mongodb/mongod.log" timeout 30 bash -c " - until mongosh --host localhost:27017 --eval 'db.runCommand({ ping: 1 })' >/dev/null 2>&1; do + until mongosh --host localhost:27017 --quiet --eval 'db.runCommand({ ping: 1 })' >/dev/null 2>&1; do echo 'MongoDB not ready yet, retrying in 1 second...' sleep 1 done " - shell: bash + mongod --version | head -1 From 600c60e158424d3f9b233ec47e76cff1e79c3fdd Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 13 Oct 2026 16:04:18 +0200 Subject: [PATCH 09/55] Keep the MongoDB check a block scalar, since a plain one reads its colon as a mapping Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bec131..abcaea6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,8 @@ jobs: - name: Start MongoDB uses: ./actions/mongodb/start - name: Verify MongoDB answers - run: mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' mongodb://127.0.0.1:27017 | grep -qx 1 + run: | + mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' mongodb://127.0.0.1:27017 | grep -qx 1 test-syft-generate-sbom: name: "Test / Syft generate-sbom action" From 67a46224f4fd4a13eab6ee0f7049166ee1fb8ba5 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 15 Oct 2026 15:45:16 +0200 Subject: [PATCH 10/55] Let the container scan build the base image of the commit first, and hand it in as BASE_IMAGE An image built on another one of the same repository was scanned on the published base, so the scan read a base this commit does not build. When the caller is this repository, the scan uses the actions of its checkout, as the Terraform quality workflow does. A CI job checks the derived image is built on a base marked by the run, and .istarci.yml gives the jobs the Docker socket it builds with. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BeKfwoPiMUSurT6NZGAPhP --- .github/workflows/ci.yml | 10 +++++ .github/workflows/reusable-container-scan.yml | 40 ++++++++++++++++++- .istarci.yml | 5 +++ test/fixtures/container/base/Dockerfile | 3 ++ test/fixtures/container/derived/Dockerfile | 4 ++ 5 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 .istarci.yml create mode 100644 test/fixtures/container/base/Dockerfile create mode 100644 test/fixtures/container/derived/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abcaea6..2a3c027 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,3 +131,13 @@ jobs: uses: ./.github/workflows/reusable-terraform-quality.yml with: working-directory: test/fixtures/terraform + + test-container-scan-on-base: + name: "Test / Container scan reusable workflow on a base built in the run" + uses: ./.github/workflows/reusable-container-scan.yml + with: + base-image-definition: test/fixtures/container/base/Dockerfile + image-definition: test/fixtures/container/derived/Dockerfile + image-name: scan-test + image-path: local + image-tag: ${{ github.run_id }} diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index 7a654b5..3875e0f 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -3,6 +3,16 @@ name: Reusable - Container scan on: workflow_call: inputs: + base-image-argument: + description: Build argument the image definition reads the base image from + type: string + required: false + default: BASE_IMAGE + base-image-definition: + description: Path to the definition of the image this one is built on, built first so that the scan reads what this commit produces rather than a published base + type: string + required: false + default: "" image-definition: description: Path to the container definition file (Dockerfile, Containerfile) type: string @@ -50,7 +60,7 @@ on: required: false default: true workflow-parts-version: - description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself." + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." type: string required: false default: "main" @@ -70,7 +80,14 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v7 + - name: Use the workflow parts of this repository + if: github.repository == 'devpro/github-workflow-parts' + shell: bash + run: | + mkdir -p "$GITHUB_WORKSPACE/workflow-parts" + cp -r "$GITHUB_WORKSPACE/actions" "$GITHUB_WORKSPACE/workflow-parts/" - name: Checkout workflow parts + if: github.repository != 'devpro/github-workflow-parts' uses: actions/checkout@v7 with: repository: devpro/github-workflow-parts @@ -78,9 +95,27 @@ jobs: path: workflow-parts # A scan judges what a clean build produces today. # A cached `apt-get upgrade` layer or a stale base image keeps an already fixed package version, on a runner whose Docker daemon outlives the job. + - name: Build base container image + if: ${{ inputs.base-image-definition != '' }} + run: docker build . --pull --no-cache --file "$BASE_DEFINITION" --tag "$BASE_REF" + shell: bash + env: + BASE_DEFINITION: ${{ inputs.base-image-definition }} + # An image built on the base above reads it from the local daemon, which a pull would look for in a registry that has never seen it. - name: Build container image - run: docker build . --pull --no-cache --file ${{ inputs.image-definition }} --tag ${{ env.IMAGE_REF }} + run: | + args=(--no-cache --file "$IMAGE_DEFINITION" --tag "$IMAGE_REF") + if [ -n "$BASE_DEFINITION" ]; then + args+=(--build-arg "${BASE_ARGUMENT}=${BASE_REF}") + else + args+=(--pull) + fi + docker build . "${args[@]}" shell: bash + env: + BASE_ARGUMENT: ${{ inputs.base-image-argument }} + BASE_DEFINITION: ${{ inputs.base-image-definition }} + IMAGE_DEFINITION: ${{ inputs.image-definition }} # deactivated 2026-02-22: "Build container for action use... process "/bin/sh -c zypper in -y jq docker && zypper clean" did not complete successfully: exit code: 8" # - name: Scan container image with NeuVector # if: ${{ inputs.neuvector-enabled }} @@ -105,3 +140,4 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} IMAGE_REF: ${{ inputs.image-path }}/${{ inputs.image-name }}:${{ inputs.image-tag }} + BASE_REF: ${{ inputs.image-path }}/${{ inputs.image-name }}-base:${{ inputs.image-tag }} diff --git a/.istarci.yml b/.istarci.yml new file mode 100644 index 0000000..7b7d4d8 --- /dev/null +++ b/.istarci.yml @@ -0,0 +1,5 @@ +# IstarCI configuration, read from the root of the repository. + +runner: + # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does. + docker: true diff --git a/test/fixtures/container/base/Dockerfile b/test/fixtures/container/base/Dockerfile new file mode 100644 index 0000000..dd3ca4f --- /dev/null +++ b/test/fixtures/container/base/Dockerfile @@ -0,0 +1,3 @@ +# A base image marked as built by this run, which the derived image checks it was built on. +FROM busybox:1.37 +RUN echo built-in-this-run > /base-marker diff --git a/test/fixtures/container/derived/Dockerfile b/test/fixtures/container/derived/Dockerfile new file mode 100644 index 0000000..dab611b --- /dev/null +++ b/test/fixtures/container/derived/Dockerfile @@ -0,0 +1,4 @@ +# The default names a base no registry has, so a build ignoring the base of the run fails rather than passing on a published one. +ARG BASE_IMAGE=registry.invalid/never-published:latest +FROM ${BASE_IMAGE} +RUN test "$(cat /base-marker)" = built-in-this-run From 9b6399d26f59294bc8de9d5d6f951774cc51bc8e Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 15 Oct 2026 17:45:51 +0200 Subject: [PATCH 11/55] Give the Docker socket to the jobs that use it alone, the others keeping a network of their own Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BeKfwoPiMUSurT6NZGAPhP --- .istarci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.istarci.yml b/.istarci.yml index 7b7d4d8..1062282 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -2,4 +2,4 @@ runner: # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does. - docker: true + docker: ["test-container-scan-on-base*"] \ No newline at end of file From de4a820b4c6c0df4d2186dfc4f6b0234dc5795d0 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 15 Oct 2026 17:55:51 +0200 Subject: [PATCH 12/55] End .istarci.yml with a new line, which the YAML lint asks for Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BeKfwoPiMUSurT6NZGAPhP --- .istarci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.istarci.yml b/.istarci.yml index 1062282..d8e9576 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -2,4 +2,4 @@ runner: # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does. - docker: ["test-container-scan-on-base*"] \ No newline at end of file + docker: ["test-container-scan-on-base*"] From d691c422d1f245c51a657efc41ce1ba60362d19f Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 16 Oct 2026 17:51:26 +0200 Subject: [PATCH 13/55] Read the Sonar host, FOSSA switch and test filter from the caller's variables, and build .NET code in its working directory Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfFh6KhG7gWxW2NtewgcK9 --- .github/workflows/ci.yml | 8 +++++ .github/workflows/reusable-dotnet-quality.yml | 33 ++++++++++++++----- .istarci.yml | 3 ++ actions/dotnet/build-test-sonar/action.yml | 15 +++++++-- actions/dotnet/build-test/action.yml | 9 ++++- .../dotnet/install-lint-restore/action.yml | 6 ++++ test/fixtures/dotnet/.gitignore | 3 ++ test/fixtures/dotnet/Fixture.Tests.csproj | 15 +++++++++ test/fixtures/dotnet/KeptTests.cs | 9 +++++ test/fixtures/dotnet/LeftOutTests.cs | 10 ++++++ test/fixtures/dotnet/global.json | 5 +++ 11 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 test/fixtures/dotnet/.gitignore create mode 100644 test/fixtures/dotnet/Fixture.Tests.csproj create mode 100644 test/fixtures/dotnet/KeptTests.cs create mode 100644 test/fixtures/dotnet/LeftOutTests.cs create mode 100644 test/fixtures/dotnet/global.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a3c027..c735951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,14 @@ jobs: with: working-directory: test/fixtures/terraform + test-dotnet-quality: + name: "Test / .NET quality reusable workflow" + uses: ./.github/workflows/reusable-dotnet-quality.yml + with: + working-directory: test/fixtures/dotnet + # The fixture holds a test that fails, which only the filter keeps the job from running. + dotnet-test-filter: --filter-not-namespace Fixture.Tests.LeftOut* + test-container-scan-on-base: name: "Test / Container scan reusable workflow on a base built in the run" uses: ./.github/workflows/reusable-container-scan.yml diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 0e29425..0e78a36 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -13,6 +13,11 @@ on: type: string required: false default: "" + dotnet-test-filter: + description: "Arguments appended to the test arguments to leave tests out (for example \"--filter-not-namespace App.IntegrationTests*\"), the caller's DOTNET_TEST_FILTER variable when empty" + type: string + required: false + default: "" dotnet-version: description: .NET version type: string @@ -24,7 +29,7 @@ on: required: false default: "" fossa-enabled: - description: Enable license compliance with FOSSA + description: Enable license compliance with FOSSA, unless the caller's FOSSA_ENABLED variable is "false" type: boolean required: false default: false @@ -64,10 +69,10 @@ on: required: false default: "node_modules" sonar-host-url: - description: Sonar host URL + description: Sonar host URL, the caller's SONAR_HOST_URL variable when empty, then SonarCloud type: string required: false - default: "https://sonarcloud.io" + default: "" sonar-organization: description: Sonar organization type: string @@ -84,7 +89,7 @@ on: required: false default: "" workflow-parts-version: - description: GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. + description: GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test. type: string required: false default: main @@ -141,7 +146,15 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 + - name: Use the workflow parts of this repository + if: github.repository == 'devpro/github-workflow-parts' + shell: bash + working-directory: . + run: | + mkdir -p "$GITHUB_WORKSPACE/workflow-parts" + cp -r "$GITHUB_WORKSPACE/actions" "$GITHUB_WORKSPACE/workflow-parts/" - name: Checkout workflow parts + if: github.repository != 'devpro/github-workflow-parts' uses: actions/checkout@v7 with: repository: devpro/github-workflow-parts @@ -151,6 +164,7 @@ jobs: uses: ./workflow-parts/actions/dotnet/install-lint-restore with: dotnet-version: ${{ inputs.dotnet-version }} + working-directory: ${{ inputs.working-directory }} - name: Start MongoDB if: ${{ inputs.mongodb-enabled }} uses: ./workflow-parts/actions/mongodb/start @@ -162,21 +176,24 @@ jobs: if: ${{ ! inputs.sonar-enabled }} uses: ./workflow-parts/actions/dotnet/build-test with: - dotnet-test-args: ${{ inputs.dotnet-test-args }} + dotnet-test-args: ${{ format('{0} {1}', inputs.dotnet-test-args, inputs.dotnet-test-filter || vars.DOTNET_TEST_FILTER) }} + working-directory: ${{ inputs.working-directory }} - name: Build, test & analyze if: ${{ inputs.sonar-enabled }} uses: ./workflow-parts/actions/dotnet/build-test-sonar with: - dotnet-test-args: ${{ inputs.dotnet-test-args }} + dotnet-test-args: ${{ format('{0} {1}', inputs.dotnet-test-args, inputs.dotnet-test-filter || vars.DOTNET_TEST_FILTER) }} + working-directory: ${{ inputs.working-directory }} sonar-cpd-exclusions: ${{ inputs.sonar-cpd-exclusions }} sonar-exclusions: ${{ inputs.sonar-exclusions }} - sonar-host-url: ${{ inputs.sonar-host-url }} + sonar-host-url: ${{ inputs.sonar-host-url || vars.SONAR_HOST_URL || 'https://sonarcloud.io' }} sonar-organization: ${{ inputs.sonar-organization }} sonar-project-key: ${{ inputs.sonar-project-key }} sonar-project-name: ${{ inputs.sonar-project-name }} sonar-token: ${{ secrets.sonar-token }} - name: Check license compliance with FOSSA - if: ${{ inputs.fossa-enabled }} + # A repository turns it off where FOSSA cannot be reached, a local run holding no API key. + if: ${{ inputs.fossa-enabled && vars.FOSSA_ENABLED != 'false' }} uses: ./workflow-parts/actions/fossa/analyze # https://status.fossa.com/ continue-on-error: true diff --git a/.istarci.yml b/.istarci.yml index d8e9576..b70b731 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -1,5 +1,8 @@ # IstarCI configuration, read from the root of the repository. runner: + # The .NET quality workflow needs the SDK, which a hosted runner has and the default image does not. + images: + "test-dotnet-quality*": ghcr.io/devpro/ubuntu-dotnet:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does. docker: ["test-container-scan-on-base*"] diff --git a/actions/dotnet/build-test-sonar/action.yml b/actions/dotnet/build-test-sonar/action.yml index 5cadcc9..a736ab5 100644 --- a/actions/dotnet/build-test-sonar/action.yml +++ b/actions/dotnet/build-test-sonar/action.yml @@ -42,6 +42,10 @@ inputs: description: Sonar token for login required: false default: "" + working-directory: + description: Directory of the .NET code, relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." runs: using: "composite" @@ -72,7 +76,7 @@ runs: shell: bash - name: Start code analysis run: | - ./.sonar/scanner/dotnet-sonarscanner begin \ + "$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" begin \ /k:"${{ inputs.sonar-project-key }}" \ /o:"${{ inputs.sonar-organization }}" \ /n:"${{ inputs.sonar-project-name }}" \ @@ -83,9 +87,11 @@ runs: /d:sonar.coverageReportPaths="${{ inputs.report-folder }}/SonarQube.xml" \ /d:sonar.cs.vstest.reportsPaths="${{ inputs.report-folder }}/TestResults/*.trx" shell: bash + working-directory: ${{ inputs.working-directory }} - name: Build .NET solution run: dotnet build --no-restore --configuration Debug shell: bash + working-directory: ${{ inputs.working-directory }} env: BUILD_SOURCEBRANCHNAME: ${{ github.ref_name }} BUILD_BUILDID: ${{ github.run_id }} @@ -95,15 +101,18 @@ runs: --results-directory "${{ inputs.report-folder }}/TestResults" \ ${{ inputs.dotnet-test-args }} shell: bash + working-directory: ${{ inputs.working-directory }} env: ASPNETCORE_ENVIRONMENT: Development - name: Generate test report - if: ${{ always() && hashFiles(format('{0}/TestResults/*.cobertura.xml', inputs.report-folder)) != '' }} + if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} run: | reportgenerator "-reports:${{ inputs.report-folder }}/TestResults/*.cobertura.xml" \ "-targetdir:${{ inputs.report-folder }}" \ "-reporttypes:Cobertura;Html;TextSummary;SonarQube" shell: bash + working-directory: ${{ inputs.working-directory }} - name: Complete code analysis - run: ./.sonar/scanner/dotnet-sonarscanner end /d:sonar.token="${{ inputs.sonar-token }}" + run: '"$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" end /d:sonar.token="${{ inputs.sonar-token }}"' shell: bash + working-directory: ${{ inputs.working-directory }} diff --git a/actions/dotnet/build-test/action.yml b/actions/dotnet/build-test/action.yml index d13591e..7a1aee5 100644 --- a/actions/dotnet/build-test/action.yml +++ b/actions/dotnet/build-test/action.yml @@ -10,6 +10,10 @@ inputs: description: Folder where report files will be generated required: false default: report + working-directory: + description: Directory of the .NET code, relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." runs: using: "composite" @@ -17,6 +21,7 @@ runs: - name: Build .NET code run: dotnet build --no-restore --configuration Debug shell: bash + working-directory: ${{ inputs.working-directory }} env: BUILD_SOURCEBRANCHNAME: ${{ github.ref_name }} BUILD_BUILDID: ${{ github.run_id }} @@ -26,12 +31,14 @@ runs: --results-directory "${{ inputs.report-folder }}/TestResults" \ ${{ inputs.dotnet-test-args }} shell: bash + working-directory: ${{ inputs.working-directory }} env: ASPNETCORE_ENVIRONMENT: Development - name: Generate test report - if: ${{ always() && hashFiles(format('{0}/TestResults/*.cobertura.xml', inputs.report-folder)) != '' }} + if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} run: | reportgenerator "-reports:${{ inputs.report-folder }}/TestResults/*.cobertura.xml" \ "-targetdir:${{ inputs.report-folder }}" \ "-reporttypes:Cobertura;Html;TextSummary" shell: bash + working-directory: ${{ inputs.working-directory }} diff --git a/actions/dotnet/install-lint-restore/action.yml b/actions/dotnet/install-lint-restore/action.yml index c1fa6ee..ae881f6 100644 --- a/actions/dotnet/install-lint-restore/action.yml +++ b/actions/dotnet/install-lint-restore/action.yml @@ -6,6 +6,10 @@ inputs: description: .NET SDK version to be used required: false default: "10.0" + working-directory: + description: Directory of the .NET code, relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." runs: using: "composite" @@ -26,6 +30,8 @@ runs: - name: Restore .NET packages run: dotnet restore shell: bash + working-directory: ${{ inputs.working-directory }} - name: Lint .NET code run: dotnet format --verify-no-changes --severity warn --verbosity:diagnostic shell: bash + working-directory: ${{ inputs.working-directory }} diff --git a/test/fixtures/dotnet/.gitignore b/test/fixtures/dotnet/.gitignore new file mode 100644 index 0000000..8ee3b66 --- /dev/null +++ b/test/fixtures/dotnet/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +report/ diff --git a/test/fixtures/dotnet/Fixture.Tests.csproj b/test/fixtures/dotnet/Fixture.Tests.csproj new file mode 100644 index 0000000..3729471 --- /dev/null +++ b/test/fixtures/dotnet/Fixture.Tests.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + Exe + enable + enable + false + + + + + + + diff --git a/test/fixtures/dotnet/KeptTests.cs b/test/fixtures/dotnet/KeptTests.cs new file mode 100644 index 0000000..57abe55 --- /dev/null +++ b/test/fixtures/dotnet/KeptTests.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace Fixture.Tests.Kept; + +public class KeptTests +{ + [Fact] + public void Passes() => Assert.True(true); +} diff --git a/test/fixtures/dotnet/LeftOutTests.cs b/test/fixtures/dotnet/LeftOutTests.cs new file mode 100644 index 0000000..11f7b44 --- /dev/null +++ b/test/fixtures/dotnet/LeftOutTests.cs @@ -0,0 +1,10 @@ +using Xunit; + +namespace Fixture.Tests.LeftOut; + +// Fails on purpose: the CI job passes only when dotnet-test-filter leaves this namespace out. +public class LeftOutTests +{ + [Fact] + public void FailsUnlessLeftOut() => Assert.Fail("dotnet-test-filter did not leave this test out"); +} diff --git a/test/fixtures/dotnet/global.json b/test/fixtures/dotnet/global.json new file mode 100644 index 0000000..3140116 --- /dev/null +++ b/test/fixtures/dotnet/global.json @@ -0,0 +1,5 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + } +} From 6c8d9500263dbd127c2582ea6619c4351e98a945 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 16 Oct 2026 17:55:45 +0200 Subject: [PATCH 14/55] Check out the commit under test for the workflow parts when the caller is this repository Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfFh6KhG7gWxW2NtewgcK9 --- .github/workflows/reusable-dotnet-quality.yml | 11 ++--------- .github/workflows/reusable-terraform-quality.yml | 10 ++-------- 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 0e78a36..7d4d950 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -146,19 +146,12 @@ jobs: uses: actions/checkout@v7 with: fetch-depth: 0 - - name: Use the workflow parts of this repository - if: github.repository == 'devpro/github-workflow-parts' - shell: bash - working-directory: . - run: | - mkdir -p "$GITHUB_WORKSPACE/workflow-parts" - cp -r "$GITHUB_WORKSPACE/actions" "$GITHUB_WORKSPACE/workflow-parts/" - name: Checkout workflow parts - if: github.repository != 'devpro/github-workflow-parts' uses: actions/checkout@v7 with: repository: devpro/github-workflow-parts - ref: ${{ inputs.workflow-parts-version }} + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts - name: Install .NET & lint uses: ./workflow-parts/actions/dotnet/install-lint-restore diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index 895f765..fc7726c 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -34,18 +34,12 @@ jobs: steps: - name: Clone repository uses: actions/checkout@v7 - - name: Use the workflow parts of this repository - if: github.repository == 'devpro/github-workflow-parts' - shell: bash - run: | - mkdir -p "$GITHUB_WORKSPACE/workflow-parts" - cp -r "$GITHUB_WORKSPACE/actions" "$GITHUB_WORKSPACE/workflow-parts/" - name: Checkout workflow parts - if: github.repository != 'devpro/github-workflow-parts' uses: actions/checkout@v7 with: repository: devpro/github-workflow-parts - ref: ${{ inputs.workflow-parts-version }} + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts - name: Cache Terraform plugins uses: actions/cache@v6 From 5ac3e2387d3dcb18c0a6a3299edde9a59c27b635 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Fri, 16 Oct 2026 17:56:57 +0200 Subject: [PATCH 15/55] Indent the C# of the .NET fixture by four, as dotnet format expects Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfFh6KhG7gWxW2NtewgcK9 --- test/fixtures/dotnet/.editorconfig | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 test/fixtures/dotnet/.editorconfig diff --git a/test/fixtures/dotnet/.editorconfig b/test/fixtures/dotnet/.editorconfig new file mode 100644 index 0000000..78de46e --- /dev/null +++ b/test/fixtures/dotnet/.editorconfig @@ -0,0 +1,11 @@ +# C# is indented by four, where the rest of this repository is indented by two. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.cs] +indent_style = space +indent_size = 4 From 471e409a2888f765b3f192a3ded9599c3aa8a779 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 12:48:10 +0200 Subject: [PATCH 16/55] Test the actions and workflows statically and in the CI, and fix what the tests found The static checks run actionlint, shellcheck on the scripts of the composite actions, and the conventions of this repository. The CI runs the actions it did not run yet, checks the version inputs, and checks that a missing release, a wrong checksum or a finding fails. Inputs and secrets reach the scripts through env variables, curl fails on an HTTP error, and a failed checksum prints its error. Every reusable workflow tests the workflow parts of its own commit when the caller is this repository. The official actions and the linters are pinned to a commit or a version. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/ci.yml | 180 +++++++- .../reusable-container-publication.yml | 15 +- .github/workflows/reusable-container-scan.yml | 14 +- .github/workflows/reusable-dotnet-quality.yml | 8 +- .github/workflows/reusable-markup-lint.yml | 7 +- .../reusable-terraform-deployment.yml | 25 +- .../workflows/reusable-terraform-quality.yml | 12 +- .istarci.yml | 8 +- CONTRIBUTING.md | 5 + README.md | 46 +- actions/chart-testing/setup/action.yml | 4 +- actions/cosign/sign/action.yml | 37 +- actions/dotnet/build-test-sonar/action.yml | 46 +- actions/dotnet/build-test/action.yml | 9 +- .../dotnet/install-lint-restore/action.yml | 2 +- actions/dotnet/pack-push/action.yml | 22 +- actions/fossa/analyze/action.yml | 8 +- actions/goreleaser/setup/action.yml | 8 +- actions/helm/setup/action.yml | 4 +- .../mongodb-atlas/add-runner-ip/action.yml | 10 +- actions/syft/generate-sbom/action.yml | 10 +- actions/terraform/setup/action.yml | 8 +- actions/tflint/setup/action.yml | 8 +- actions/trivy/scan/action.yml | 8 +- test/check_workflow_parts.py | 415 ++++++++++++++++++ test/fixtures/dotnet-library/.editorconfig | 11 + test/fixtures/dotnet-library/.gitignore | 3 + .../dotnet-library/Fixture.Library.csproj | 11 + test/fixtures/dotnet-library/Greeting.cs | 7 + test/requirements.txt | 81 ++++ test/run_static_checks.sh | 50 +++ 31 files changed, 930 insertions(+), 152 deletions(-) create mode 100755 test/check_workflow_parts.py create mode 100644 test/fixtures/dotnet-library/.editorconfig create mode 100644 test/fixtures/dotnet-library/.gitignore create mode 100644 test/fixtures/dotnet-library/Fixture.Library.csproj create mode 100644 test/fixtures/dotnet-library/Greeting.cs create mode 100644 test/requirements.txt create mode 100755 test/run_static_checks.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c735951..043348a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,12 +19,21 @@ jobs: name: Markup uses: ./.github/workflows/reusable-markup-lint.yml + static-checks: + name: "Test / Static checks of the actions and workflows" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Run actionlint, shellcheck and the conventions of this repository + run: test/run_static_checks.sh + test-terraform-setup: name: "Test / Terraform setup action" runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install terraform uses: ./actions/terraform/setup - name: Verify terraform is installed @@ -37,7 +46,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup TFLint uses: ./actions/tflint/setup - name: Verify tflint is installed @@ -45,12 +54,83 @@ jobs: tflint --version command -v tflint + test-setup-versions: + name: "Test / Setup actions install the version asked for" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install terraform + uses: ./actions/terraform/setup + with: + terraform-version: 1.9.8 + - name: Setup TFLint + uses: ./actions/tflint/setup + with: + tflint-version: v0.53.0 + - name: Setup Helm + uses: ./actions/helm/setup + with: + helm-version: v3.16.4 + - name: Verify the versions + run: | + terraform version | head -1 | grep -qx 'Terraform v1.9.8' + tflint --version | grep -q 'version 0.53.0' + helm version --short | grep -q '^v3.16.4+' + + # A setup action that cannot verify what it downloaded stops the job rather than running it. + test-setup-fails-closed: + name: "Test / Setup actions fail on a missing release or a wrong checksum" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install a terraform release that does not exist + id: terraform-missing-release + continue-on-error: true + uses: ./actions/terraform/setup + with: + terraform-version: 0.0.0 + - name: Install a Trivy release that does not exist + id: trivy-missing-release + continue-on-error: true + uses: ./actions/trivy/scan + with: + trivy-version: 0.0.0 + scan-type: config + target: test/fixtures/terraform + - name: Install a Cosign release that does not exist + id: cosign-missing-release + continue-on-error: true + uses: ./actions/cosign/sign + with: + cosign-version: v0.0.0 + image-name: busybox + image-path: docker.io/library + image-tag: "1.37" + readonly: "true" + - name: Start MongoDB with a mongosh archive whose checksum does not match + id: mongodb-wrong-checksum + continue-on-error: true + uses: ./actions/mongodb/start + with: + mongosh-sha256: "0000000000000000000000000000000000000000000000000000000000000000" + - name: Verify every step failed + env: + STEPS: ${{ toJSON(steps) }} + run: | + passed=$(jq -r 'to_entries[] | select(.value.outcome != "failure") | .key' <<< "$STEPS") + if [ -n "$passed" ]; then + echo "Steps that did not fail: $passed" >&2 + exit 1 + fi + test-helm-setup: name: "Test / Helm setup action" runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Helm uses: ./actions/helm/setup - name: Verify helm is installed @@ -63,7 +143,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Helm uses: ./actions/helm/setup - name: Setup chart-testing @@ -73,12 +153,72 @@ jobs: ct version command -v ct yamllint yamale + test-goreleaser-setup: + name: "Test / GoReleaser setup action" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup GoReleaser + uses: ./actions/goreleaser/setup + - name: Verify goreleaser is installed + run: | + goreleaser --version + command -v goreleaser + + test-cosign-sign: + name: "Test / Cosign sign action, without signing" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # Signing needs an OIDC token and a registry to push to, so the run stops short of it. + - name: Install Cosign and read the digest of a public image + id: cosign + uses: ./actions/cosign/sign + with: + image-name: busybox + image-path: docker.io/library + image-tag: "1.37" + readonly: "true" + - name: Read the digest of an image that does not exist + id: missing-image + continue-on-error: true + uses: ./actions/cosign/sign + with: + image-name: busybox + image-path: docker.io/library + image-tag: tag-that-does-not-exist + readonly: "true" + - name: Verify the digest and the failure + env: + DIGEST: ${{ steps.cosign.outputs.digest }} + MISSING_IMAGE_OUTCOME: ${{ steps.missing-image.outcome }} + run: | + command -v cosign + [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + [ "$MISSING_IMAGE_OUTCOME" = failure ] + + test-dotnet-pack-push: + name: "Test / .NET pack-push action, without pushing" + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Package the library fixture + uses: ./actions/dotnet/pack-push + with: + registry-token: unused + working-directory: test/fixtures/dotnet-library + - name: Verify the package was created + run: test -s test/fixtures/dotnet-library/output/Fixture.Library.1.0.0.nupkg + test-mongodb-start: name: "Test / MongoDB start action" runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Start MongoDB uses: ./actions/mongodb/start - name: Verify MongoDB answers @@ -90,24 +230,32 @@ jobs: runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Generate SBOM for this repository uses: ./actions/syft/generate-sbom with: target-path: actions output-file: sbom-test.spdx.json upload-artifact: "false" + - name: Generate SBOM for a public image + uses: ./actions/syft/generate-sbom + with: + target-image: busybox:1.37 + output-format: cyclonedx-json + output-file: sbom-image-test.cdx.json + upload-artifact: "false" - name: Verify SBOM was generated run: | test -s sbom-test.spdx.json grep -q '"spdxVersion"' sbom-test.spdx.json + jq -e '.bomFormat == "CycloneDX" and (.components | length > 0)' sbom-image-test.cdx.json test-trivy-scan: name: "Test / Trivy scan action" runs-on: ubuntu-latest steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Scan a real image with Trivy uses: ./actions/trivy/scan with: @@ -123,8 +271,22 @@ jobs: format: sarif output-file: trivy-test.sarif severity: HIGH,CRITICAL - - name: Verify SARIF report was generated - run: test -s trivy-test.sarif + # The base fixture runs as root, which Trivy reports as a HIGH misconfiguration. + - name: Scan a directory with a finding, failing on it + id: trivy-finding + continue-on-error: true + uses: ./actions/trivy/scan + with: + scan-type: config + target: test/fixtures/container/base + severity: HIGH,CRITICAL + exit-code: "1" + - name: Verify SARIF report was generated and the finding failed the scan + env: + FINDING_OUTCOME: ${{ steps.trivy-finding.outcome }} + run: | + jq -e '.version == "2.1.0"' trivy-test.sarif + [ "$FINDING_OUTCOME" = failure ] test-terraform-quality: name: "Test / Terraform quality reusable workflow" diff --git a/.github/workflows/reusable-container-publication.yml b/.github/workflows/reusable-container-publication.yml index 396388d..00f42db 100644 --- a/.github/workflows/reusable-container-publication.yml +++ b/.github/workflows/reusable-container-publication.yml @@ -58,7 +58,7 @@ on: required: false default: ubuntu-latest workflow-parts-version: - description: GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. + description: GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test. type: string required: false default: main @@ -92,7 +92,7 @@ jobs: SECRET_VARS: ${{ secrets.secret-vars }} run: | if [[ -n "$SECRET_VARS" ]]; then - echo "$SECRET_VARS" | while IFS='=' read -r key val; do + echo "$SECRET_VARS" | while IFS='=' read -r _ val; do if [[ -n "$val" ]]; then echo "::add-mask::$val" fi @@ -100,12 +100,13 @@ jobs: echo "$SECRET_VARS" >> "$GITHUB_ENV" fi - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout workflow parts - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: devpro/github-workflow-parts - ref: ${{ inputs.workflow-parts-version }} + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts # The Docker CLI of the runner does what the docker/* actions wrapped, with no third party action in between. - name: Login to container registry @@ -140,9 +141,7 @@ jobs: - name: Push latest tag if: ${{ inputs.create-latest }} run: | - docker buildx imagetools create \ - --tag ${{ env.IMAGE_REF_LATEST }} \ - ${{ env.IMAGE_REF }} + docker buildx imagetools create --tag "$IMAGE_REF_LATEST" "$IMAGE_REF" - name: Sign container image with Cosign uses: ./workflow-parts/actions/cosign/sign with: diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index 3875e0f..183b460 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -79,19 +79,13 @@ jobs: working-directory: ${{ inputs.working-directory }} steps: - name: Clone repository - uses: actions/checkout@v7 - - name: Use the workflow parts of this repository - if: github.repository == 'devpro/github-workflow-parts' - shell: bash - run: | - mkdir -p "$GITHUB_WORKSPACE/workflow-parts" - cp -r "$GITHUB_WORKSPACE/actions" "$GITHUB_WORKSPACE/workflow-parts/" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout workflow parts - if: github.repository != 'devpro/github-workflow-parts' - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: devpro/github-workflow-parts - ref: ${{ inputs.workflow-parts-version }} + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts # A scan judges what a clean build produces today. # A cached `apt-get upgrade` layer or a stale base image keeps an already fixed package version, on a runner whose Docker daemon outlives the job. diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 7d4d950..b082193 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -131,7 +131,7 @@ jobs: ADDITIONAL_VARS: ${{ secrets.additional-vars }} run: | if [[ -n "$ADDITIONAL_VARS" ]]; then - echo "$ADDITIONAL_VARS" | while IFS='=' read -r key val; do + echo "$ADDITIONAL_VARS" | while IFS='=' read -r _ val; do if [[ -n "$val" ]]; then echo "::add-mask::$val" fi @@ -143,11 +143,11 @@ jobs: run: git config --global core.autocrlf false shell: bash - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - name: Checkout workflow parts - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: devpro/github-workflow-parts # The caller being this repository, the actions under test are those of the commit under test. @@ -201,7 +201,7 @@ jobs: continue-on-error: true - name: Archive test results if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dotnet-test-results path: | diff --git a/.github/workflows/reusable-markup-lint.yml b/.github/workflows/reusable-markup-lint.yml index 54e8dd1..7868ae2 100644 --- a/.github/workflows/reusable-markup-lint.yml +++ b/.github/workflows/reusable-markup-lint.yml @@ -28,8 +28,9 @@ jobs: working-directory: ${{ inputs.working-directory }} steps: - name: Check out repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The linters are pinned, so that a new release changes the verdict on a repository only when this one moves to it. - name: Lint Markdown files - run: npx markdownlint-cli2 "**/*.md" + run: npx --yes markdownlint-cli2@0.23.3 "**/*.md" - name: Lint YAML files - run: pipx run yamllint . + run: pipx run --spec yamllint==1.38.0 yamllint . diff --git a/.github/workflows/reusable-terraform-deployment.yml b/.github/workflows/reusable-terraform-deployment.yml index c2bc2ed..4359995 100644 --- a/.github/workflows/reusable-terraform-deployment.yml +++ b/.github/workflows/reusable-terraform-deployment.yml @@ -1,4 +1,4 @@ -name: Reusable - Terraform apply +name: Reusable - Terraform apply on: workflow_call: @@ -37,7 +37,7 @@ on: required: false default: "" workflow-parts-version: - description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself." + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." type: string required: false default: "main" @@ -49,7 +49,7 @@ on: description: "Atlas private key" required: true atlas-groupid: - description: "Atlas group IP" + description: "Atlas group ID" required: true tfbackend-connstring: description: "Terraform backend connection string" @@ -90,12 +90,13 @@ jobs: - 8080:8080 steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout workflow parts - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: devpro/github-workflow-parts - ref: ${{ inputs.workflow-parts-version }} + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts - name: Add runner ID to MongoDB Atlas uses: ./workflow-parts/actions/mongodb-atlas/add-runner-ip @@ -104,23 +105,23 @@ jobs: atlas-privatekey: ${{ secrets.atlas-privatekey }} atlas-groupid: ${{ secrets.atlas-groupid }} - name: Set additional variables + env: + ADDITIONAL_VARS: ${{ secrets.additional-vars }} run: | - if [[ -z "${{ secrets.additional-vars }}" ]]; then - echo "No additional-vars bundle provided - skipping." - else - echo "${{ secrets.additional-vars }}" | while IFS='=' read -r key val; do + if [[ -n "$ADDITIONAL_VARS" ]]; then + echo "$ADDITIONAL_VARS" | while IFS='=' read -r _ val; do if [[ -n "$val" ]]; then echo "::add-mask::$val" fi done - echo "${{ secrets.additional-vars }}" >> "$GITHUB_ENV" + echo "$ADDITIONAL_VARS" >> "$GITHUB_ENV" fi - name: Run optional custom commands if: ${{ inputs.custom-commands != '' }} run: | ${{ inputs.custom-commands }} - name: Cache Terraform plugins - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.terraform.d/plugin-cache diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index fc7726c..e7e30e7 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -33,16 +33,16 @@ jobs: working-directory: ${{ inputs.working-directory }} steps: - name: Clone repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Checkout workflow parts - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: devpro/github-workflow-parts # The caller being this repository, the actions under test are those of the commit under test. ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts - name: Cache Terraform plugins - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.terraform.d/plugin-cache @@ -58,7 +58,7 @@ jobs: # Checkov is a static code analysis tool for infrastructure as code (IaC) and also a software composition analysis (SCA) tool for images and open source packages - name: Run Checkov run: | - pipx run checkov -d . \ + pipx run --spec checkov==3.3.21 checkov -d . \ --soft-fail \ --output cli --output sarif \ --output-file-path console,results.sarif @@ -71,7 +71,7 @@ jobs: # sarif_file: results.sarif # TFLint is a pluggable terraform linter (ref. https://github.com/terraform-linters/tflint) - name: Cache TFLint plugins - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.tflint.d/plugins key: tflint-${{ hashFiles('**/.tflint.hcl') }} @@ -96,7 +96,7 @@ jobs: output-file: trivy-results.sarif severity: HIGH,CRITICAL - name: Upload SARIF as artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 if: always() with: name: security-results diff --git a/.istarci.yml b/.istarci.yml index b70b731..112e355 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -1,8 +1,8 @@ # IstarCI configuration, read from the root of the repository. runner: - # The .NET quality workflow needs the SDK, which a hosted runner has and the default image does not. + # The .NET jobs need the SDK, which a hosted runner has and the default image does not. images: - "test-dotnet-quality*": ghcr.io/devpro/ubuntu-dotnet:latest - # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does. - docker: ["test-container-scan-on-base*"] + "test-dotnet-*": ghcr.io/devpro/ubuntu-dotnet:latest + # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. + docker: ["test-container-scan-on-base*", "test-cosign-sign*"] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef7241e..a8a9e16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,3 +4,8 @@ [IstarCI](https://github.com/devpro/istarci) runs the GitHub Actions pipeline in Docker before the push. `istarci add` and `istarci install-hook` set it up for this repository. + +## Run the static checks + +`test/run_static_checks.sh` runs actionlint, shellcheck on the scripts of the composite actions, and the conventions of `test/check_workflow_parts.py`. +A part no CI job can exercise is listed in `UNTESTED_PARTS` of that file, with the reason. diff --git a/README.md b/README.md index 1ce5e2b..34a247d 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ GitHub workflow components for code repositories. > [!IMPORTANT] -> Keep your pipelines **safe** and **DRY**! +> Keep pipelines **safe** and **DRY**! ## Reusable workflows @@ -15,26 +15,21 @@ GitHub workflow components for code repositories. Containers: -- [Publish your image](.github/workflows/reusable-container-publication.yml) -- [Scan your image](.github/workflows/reusable-container-scan.yml) +- [Publish an image](.github/workflows/reusable-container-publication.yml) +- [Scan an image](.github/workflows/reusable-container-scan.yml) .NET: -- [Ensure the quality of your code](.github/workflows/reusable-dotnet-quality.yml) +- [Ensure the quality of the code](.github/workflows/reusable-dotnet-quality.yml) Markup (Markdown, YAML): -- [Ensure the quality of your content](.github/workflows/reusable-markup-lint.yml) - -Helm: - -- [Setup Helm](actions/helm/setup/action.yml) -- [Setup chart-testing](actions/chart-testing/setup/action.yml) +- [Ensure the quality of the content](.github/workflows/reusable-markup-lint.yml) Terraform: -- [Deploy your environment](.github/workflows/reusable-terraform-deployment.yml) -- [Ensure the quality of your infrastructure code](.github/workflows/reusable-terraform-quality.yml) +- [Deploy an environment](.github/workflows/reusable-terraform-deployment.yml) +- [Ensure the quality of the infrastructure code](.github/workflows/reusable-terraform-quality.yml) ## Composite actions @@ -44,18 +39,33 @@ Terraform: Containers: -- [Sign your image with Cosign](actions/cosign/sign/action.yml) +- [Sign an image with Cosign](actions/cosign/sign/action.yml) +- [Scan an image or a directory with Trivy](actions/trivy/scan/action.yml) .NET: -- [Build, test your code and analyze it with Sonar](actions/dotnet/build-test-sonar/action.yml) -- [Build and test your code](actions/dotnet/build-test/action.yml) -- [Install the SDK, lint the code and restore NuGet packages](actions/dotnet/install-lint-restore/) +- [Build, test the code and analyze it with Sonar](actions/dotnet/build-test-sonar/action.yml) +- [Build and test the code](actions/dotnet/build-test/action.yml) +- [Install the SDK, lint the code and restore NuGet packages](actions/dotnet/install-lint-restore/action.yml) +- [Package the projects and push them to a NuGet registry](actions/dotnet/pack-push/action.yml) + +Go: + +- [Setup GoReleaser](actions/goreleaser/setup/action.yml) + +Helm: + +- [Setup Helm](actions/helm/setup/action.yml) +- [Setup chart-testing](actions/chart-testing/setup/action.yml) + +License compliance: + +- [Analyze the dependencies with FOSSA](actions/fossa/analyze/action.yml) MongoDB: -- [Add your runner IP address to Atlas access list](actions/mongodb-atlas/add-runner-ip/action.yml) -- [Start a server in your pipeline](actions/mongodb/start/action.yml) +- [Add the runner IP address to the Atlas access list](actions/mongodb-atlas/add-runner-ip/action.yml) +- [Start a server in the pipeline](actions/mongodb/start/action.yml) SBOM: diff --git a/actions/chart-testing/setup/action.yml b/actions/chart-testing/setup/action.yml index 7873866..5babac7 100644 --- a/actions/chart-testing/setup/action.yml +++ b/actions/chart-testing/setup/action.yml @@ -35,8 +35,8 @@ runs: BASE_URL="https://github.com/helm/chart-testing/releases/download/${CT_VERSION}" echo "Downloading chart-testing binary and checksums..." - curl -sSL -O "${BASE_URL}/${CT_TAR}" - curl -sSL -O "${BASE_URL}/checksums.txt" + curl -fsSL -O "${BASE_URL}/${CT_TAR}" + curl -fsSL -O "${BASE_URL}/checksums.txt" echo "Verifying checksum..." if grep " ${CT_TAR}$" checksums.txt | sha256sum --check --status; then diff --git a/actions/cosign/sign/action.yml b/actions/cosign/sign/action.yml index f754b0e..844359c 100644 --- a/actions/cosign/sign/action.yml +++ b/actions/cosign/sign/action.yml @@ -26,6 +26,11 @@ inputs: required: false default: 'false' +outputs: + digest: + description: Digest of the image, which the signature is attached to + value: ${{ steps.digest.outputs.DIGEST }} + runs: using: "composite" steps: @@ -34,19 +39,17 @@ runs: # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. cd "$(mktemp -d)" echo "Downloading Cosign binary and checksums..." - curl -sL -O https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${COSIGN_BINARY} - curl -sL -O "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${COSIGN_CHECKSUM_FILE}" + curl -fsSL -O "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${COSIGN_BINARY}" + curl -fsSL -O "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/${COSIGN_CHECKSUM_FILE}" echo "Verifying checksum..." - grep "${COSIGN_BINARY}$" "${COSIGN_CHECKSUM_FILE}" | sha256sum --check --status - - if [ $? -eq 0 ]; then + if grep " ${COSIGN_BINARY}$" "${COSIGN_CHECKSUM_FILE}" | sha256sum --check --status; then echo "Verification successful!" - rm $COSIGN_CHECKSUM_FILE - chmod +x $COSIGN_BINARY + rm "$COSIGN_CHECKSUM_FILE" + chmod +x "$COSIGN_BINARY" # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. mkdir -p "${RUNNER_TEMP}/bin" - mv $COSIGN_BINARY "${RUNNER_TEMP}/bin/cosign" + mv "$COSIGN_BINARY" "${RUNNER_TEMP}/bin/cosign" echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" export PATH="${RUNNER_TEMP}/bin:$PATH" cosign version @@ -62,15 +65,21 @@ runs: - name: Get image digest id: digest run: | - DIGEST=$(docker buildx imagetools inspect \ - ${{ inputs.image-path }}/${{ inputs.image-name }}:${{ inputs.image-tag }} \ - --format '{{json .Manifest}}' | jq -r '.digest') - echo "DIGEST=$DIGEST" >> $GITHUB_OUTPUT + DIGEST=$(docker buildx imagetools inspect "$IMAGE_REF" --format '{{json .Manifest}}' | jq -r '.digest') + # A digest missing from the registry answer would otherwise be signed as the literal text "null". + if [[ "$DIGEST" != sha256:* ]]; then + echo "ERROR: No digest found for ${IMAGE_REF}" >&2 + exit 1 + fi + echo "DIGEST=$DIGEST" >> "$GITHUB_OUTPUT" shell: bash + env: + IMAGE_REF: ${{ inputs.image-path }}/${{ inputs.image-name }}:${{ inputs.image-tag }} - name: Sign image with Cosign if: inputs.readonly != 'true' env: COSIGN_EXPERIMENTAL: 1 - run: | - cosign sign --yes ${{ inputs.image-path }}/${{ inputs.image-name }}@${{ steps.digest.outputs.DIGEST }} + IMAGE_REPOSITORY: ${{ inputs.image-path }}/${{ inputs.image-name }} + DIGEST: ${{ steps.digest.outputs.DIGEST }} + run: cosign sign --yes "${IMAGE_REPOSITORY}@${DIGEST}" shell: bash diff --git a/actions/dotnet/build-test-sonar/action.yml b/actions/dotnet/build-test-sonar/action.yml index a736ab5..c17e3e2 100644 --- a/actions/dotnet/build-test-sonar/action.yml +++ b/actions/dotnet/build-test-sonar/action.yml @@ -51,19 +51,19 @@ runs: using: "composite" steps: - name: Set up JDK for Sonar - uses: actions/setup-java@v5 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: java-version: ${{ inputs.java-version }} distribution: "zulu" - name: Cache Sonar packages - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Cache Sonar scanner id: cache-sonar-scanner - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ./.sonar/scanner key: ${{ runner.os }}-sonar-scanner @@ -77,17 +77,26 @@ runs: - name: Start code analysis run: | "$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" begin \ - /k:"${{ inputs.sonar-project-key }}" \ - /o:"${{ inputs.sonar-organization }}" \ - /n:"${{ inputs.sonar-project-name }}" \ - /d:sonar.token="${{ inputs.sonar-token}}" \ - /d:sonar.host.url="${{ inputs.sonar-host-url }}" \ - /d:sonar.cpd.exclusions="${{ inputs.report-folder }}/**,${{ inputs.sonar-cpd-exclusions }}" \ - /d:sonar.exclusions="${{ inputs.report-folder }}/**,${{ inputs.sonar-exclusions }}" \ - /d:sonar.coverageReportPaths="${{ inputs.report-folder }}/SonarQube.xml" \ - /d:sonar.cs.vstest.reportsPaths="${{ inputs.report-folder }}/TestResults/*.trx" + /k:"$SONAR_PROJECT_KEY" \ + /o:"$SONAR_ORGANIZATION" \ + /n:"$SONAR_PROJECT_NAME" \ + /d:sonar.token="$SONAR_TOKEN" \ + /d:sonar.host.url="$SONAR_HOST_URL" \ + /d:sonar.cpd.exclusions="$REPORT_FOLDER/**,$SONAR_CPD_EXCLUSIONS" \ + /d:sonar.exclusions="$REPORT_FOLDER/**,$SONAR_EXCLUSIONS" \ + /d:sonar.coverageReportPaths="$REPORT_FOLDER/SonarQube.xml" \ + /d:sonar.cs.vstest.reportsPaths="$REPORT_FOLDER/TestResults/*.trx" shell: bash working-directory: ${{ inputs.working-directory }} + env: + REPORT_FOLDER: ${{ inputs.report-folder }} + SONAR_CPD_EXCLUSIONS: ${{ inputs.sonar-cpd-exclusions }} + SONAR_EXCLUSIONS: ${{ inputs.sonar-exclusions }} + SONAR_HOST_URL: ${{ inputs.sonar-host-url }} + SONAR_ORGANIZATION: ${{ inputs.sonar-organization }} + SONAR_PROJECT_KEY: ${{ inputs.sonar-project-key }} + SONAR_PROJECT_NAME: ${{ inputs.sonar-project-name }} + SONAR_TOKEN: ${{ inputs.sonar-token }} - name: Build .NET solution run: dotnet build --no-restore --configuration Debug shell: bash @@ -98,21 +107,26 @@ runs: - name: Run tests run: | dotnet test --no-build --verbosity normal --configuration Debug \ - --results-directory "${{ inputs.report-folder }}/TestResults" \ + --results-directory "$REPORT_FOLDER/TestResults" \ ${{ inputs.dotnet-test-args }} shell: bash working-directory: ${{ inputs.working-directory }} env: ASPNETCORE_ENVIRONMENT: Development + REPORT_FOLDER: ${{ inputs.report-folder }} - name: Generate test report if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} run: | - reportgenerator "-reports:${{ inputs.report-folder }}/TestResults/*.cobertura.xml" \ - "-targetdir:${{ inputs.report-folder }}" \ + reportgenerator "-reports:$REPORT_FOLDER/TestResults/*.cobertura.xml" \ + "-targetdir:$REPORT_FOLDER" \ "-reporttypes:Cobertura;Html;TextSummary;SonarQube" shell: bash working-directory: ${{ inputs.working-directory }} + env: + REPORT_FOLDER: ${{ inputs.report-folder }} - name: Complete code analysis - run: '"$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" end /d:sonar.token="${{ inputs.sonar-token }}"' + run: '"$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" end /d:sonar.token="$SONAR_TOKEN"' shell: bash working-directory: ${{ inputs.working-directory }} + env: + SONAR_TOKEN: ${{ inputs.sonar-token }} diff --git a/actions/dotnet/build-test/action.yml b/actions/dotnet/build-test/action.yml index 7a1aee5..42f1c6f 100644 --- a/actions/dotnet/build-test/action.yml +++ b/actions/dotnet/build-test/action.yml @@ -28,17 +28,20 @@ runs: - name: Run tests run: | dotnet test --no-build --verbosity normal --configuration Debug \ - --results-directory "${{ inputs.report-folder }}/TestResults" \ + --results-directory "$REPORT_FOLDER/TestResults" \ ${{ inputs.dotnet-test-args }} shell: bash working-directory: ${{ inputs.working-directory }} env: ASPNETCORE_ENVIRONMENT: Development + REPORT_FOLDER: ${{ inputs.report-folder }} - name: Generate test report if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} run: | - reportgenerator "-reports:${{ inputs.report-folder }}/TestResults/*.cobertura.xml" \ - "-targetdir:${{ inputs.report-folder }}" \ + reportgenerator "-reports:$REPORT_FOLDER/TestResults/*.cobertura.xml" \ + "-targetdir:$REPORT_FOLDER" \ "-reporttypes:Cobertura;Html;TextSummary" shell: bash working-directory: ${{ inputs.working-directory }} + env: + REPORT_FOLDER: ${{ inputs.report-folder }} diff --git a/actions/dotnet/install-lint-restore/action.yml b/actions/dotnet/install-lint-restore/action.yml index ae881f6..9820fe3 100644 --- a/actions/dotnet/install-lint-restore/action.yml +++ b/actions/dotnet/install-lint-restore/action.yml @@ -15,7 +15,7 @@ runs: using: "composite" steps: - name: Install .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: ${{ inputs.dotnet-version }} - name: Install .NET linters diff --git a/actions/dotnet/pack-push/action.yml b/actions/dotnet/pack-push/action.yml index 324e291..f73c725 100644 --- a/actions/dotnet/pack-push/action.yml +++ b/actions/dotnet/pack-push/action.yml @@ -7,28 +7,32 @@ inputs: required: false default: "10.0" registry-token: - description: Registry + description: Registry API key required: true registry-address: description: Registry address (NuGet.org by default) required: false default: "https://api.nuget.org/v3/index.json" push-enabled: - description: Push + description: Push the packages to the registry required: false default: "false" + working-directory: + description: Directory of the .NET code, relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." runs: using: "composite" steps: - name: Install .NET - uses: actions/setup-dotnet@v6 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: dotnet-version: ${{ inputs.dotnet-version }} - name: Package - # "**/*.csproj;!**/*Tests.csproj" run: dotnet pack --configuration Release -o output shell: bash + working-directory: ${{ inputs.working-directory }} env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 @@ -36,6 +40,12 @@ runs: BUILD_BUILDID: ${{ github.run_id }} - name: Push to registry if: ${{ inputs.push-enabled == 'true' }} - # "$(Build.ArtifactStagingDirectory)/**/*.nupkg;!$(Build.ArtifactStagingDirectory)/**/*.symbols.nupkg" - run: for f in output/*.nupkg; do dotnet nuget push "$f" --api-key ${{ inputs.registry-token }} --source ${{ inputs.registry-address }} ; done + run: | + for f in output/*.nupkg; do + dotnet nuget push "$f" --api-key "$REGISTRY_TOKEN" --source "$REGISTRY_ADDRESS" + done shell: bash + working-directory: ${{ inputs.working-directory }} + env: + REGISTRY_ADDRESS: ${{ inputs.registry-address }} + REGISTRY_TOKEN: ${{ inputs.registry-token }} diff --git a/actions/fossa/analyze/action.yml b/actions/fossa/analyze/action.yml index 4fa96c2..fd3e430 100644 --- a/actions/fossa/analyze/action.yml +++ b/actions/fossa/analyze/action.yml @@ -40,14 +40,12 @@ runs: BASE_URL="https://github.com/fossas/fossa-cli/releases/download/v${FOSSA_VERSION}" echo "Downloading FOSSA CLI binary and checksum..." - curl -sSL -O "${BASE_URL}/${FOSSA_TARBALL}" - curl -sSL -O "${BASE_URL}/${FOSSA_TARBALL}.sha256" + curl -fsSL -O "${BASE_URL}/${FOSSA_TARBALL}" + curl -fsSL -O "${BASE_URL}/${FOSSA_TARBALL}.sha256" echo "Verifying checksum..." # The checksum file holds the hash, with or without the file name after it. - echo "$(cut -d ' ' -f 1 "${FOSSA_TARBALL}.sha256") ${FOSSA_TARBALL}" | sha256sum --check --status - - if [ $? -eq 0 ]; then + if echo "$(cut -d ' ' -f 1 "${FOSSA_TARBALL}.sha256") ${FOSSA_TARBALL}" | sha256sum --check --status; then echo "Verification successful!" tar -xzf "${FOSSA_TARBALL}" fossa chmod +x fossa diff --git a/actions/goreleaser/setup/action.yml b/actions/goreleaser/setup/action.yml index 4da2257..792556a 100644 --- a/actions/goreleaser/setup/action.yml +++ b/actions/goreleaser/setup/action.yml @@ -24,13 +24,11 @@ runs: BASE_URL="https://github.com/goreleaser/goreleaser/releases/download/v${GORELEASER_VERSION}" echo "Downloading GoReleaser binary and checksums..." - curl -sSL -O "${BASE_URL}/${GORELEASER_TARBALL}" - curl -sSL -O "${BASE_URL}/checksums.txt" + curl -fsSL -O "${BASE_URL}/${GORELEASER_TARBALL}" + curl -fsSL -O "${BASE_URL}/checksums.txt" echo "Verifying checksum..." - grep " ${GORELEASER_TARBALL}$" checksums.txt | sha256sum --check --status - - if [ $? -eq 0 ]; then + if grep " ${GORELEASER_TARBALL}$" checksums.txt | sha256sum --check --status; then echo "Verification successful!" tar -xzf "${GORELEASER_TARBALL}" goreleaser chmod +x goreleaser diff --git a/actions/helm/setup/action.yml b/actions/helm/setup/action.yml index d8c5f79..c988d30 100644 --- a/actions/helm/setup/action.yml +++ b/actions/helm/setup/action.yml @@ -24,8 +24,8 @@ runs: BASE_URL="https://get.helm.sh" echo "Downloading Helm binary and checksum..." - curl -sSL -O "${BASE_URL}/${HELM_TAR}" - curl -sSL -O "${BASE_URL}/${HELM_TAR}.sha256sum" + curl -fsSL -O "${BASE_URL}/${HELM_TAR}" + curl -fsSL -O "${BASE_URL}/${HELM_TAR}.sha256sum" echo "Verifying checksum..." if sha256sum --check --status "${HELM_TAR}.sha256sum"; then diff --git a/actions/mongodb-atlas/add-runner-ip/action.yml b/actions/mongodb-atlas/add-runner-ip/action.yml index 15c321f..3b1dbf9 100644 --- a/actions/mongodb-atlas/add-runner-ip/action.yml +++ b/actions/mongodb-atlas/add-runner-ip/action.yml @@ -17,9 +17,13 @@ runs: steps: - name: Add runner IP to MongoDB Atlas shell: bash + env: + ATLAS_GROUP_ID: ${{ inputs.atlas-groupid }} + ATLAS_PRIVATE_KEY: ${{ inputs.atlas-privatekey }} + ATLAS_PUBLIC_KEY: ${{ inputs.atlas-publickey }} run: | # gets current outbound IP of this runner - RUNNER_IP=$(curl -s https://api.ipify.org || curl -s https://ifconfig.me) + RUNNER_IP=$(curl -fsS https://api.ipify.org || curl -fsS https://ifconfig.me) if [ -z "$RUNNER_IP" ]; then echo "Failed to detect runner IP" exit 1 @@ -33,11 +37,11 @@ runs: PAYLOAD="[{\"ipAddress\": \"$RUNNER_IP\", \"comment\": \"GH Actions temp - run ${{ github.run_id }}\", \"deleteAfterDate\": \"$DELETE_AFTER\"}]" # calls Atlas API v2 to add the entry (uses digest Auth via curl) - RESPONSE=$(curl -s -w "%{http_code}" --user "${{ inputs.atlas-publickey }}:${{ inputs.atlas-privatekey }}" --digest \ + RESPONSE=$(curl -s -w "%{http_code}" --user "${ATLAS_PUBLIC_KEY}:${ATLAS_PRIVATE_KEY}" --digest \ -H "Accept: application/vnd.atlas.2023-01-01+json" \ -H "Content-Type: application/json" \ --data "$PAYLOAD" \ - "https://cloud.mongodb.com/api/atlas/v2/groups/${{ inputs.atlas-groupid }}/accessList") + "https://cloud.mongodb.com/api/atlas/v2/groups/${ATLAS_GROUP_ID}/accessList") HTTP_CODE=${RESPONSE: -3} diff --git a/actions/syft/generate-sbom/action.yml b/actions/syft/generate-sbom/action.yml index b06e833..7ffb258 100644 --- a/actions/syft/generate-sbom/action.yml +++ b/actions/syft/generate-sbom/action.yml @@ -50,13 +50,11 @@ runs: BASE_URL="https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}" echo "Downloading Syft binary and checksums..." - curl -sSL -O "${BASE_URL}/${SYFT_TARBALL}" - curl -sSL -O "${BASE_URL}/${SYFT_CHECKSUMS}" + curl -fsSL -O "${BASE_URL}/${SYFT_TARBALL}" + curl -fsSL -O "${BASE_URL}/${SYFT_CHECKSUMS}" echo "Verifying checksum..." - grep " ${SYFT_TARBALL}$" "${SYFT_CHECKSUMS}" | sha256sum --check --status - - if [ $? -eq 0 ]; then + if grep " ${SYFT_TARBALL}$" "${SYFT_CHECKSUMS}" | sha256sum --check --status; then echo "Verification successful!" tar -xzf "${SYFT_TARBALL}" syft chmod +x syft @@ -89,7 +87,7 @@ runs: syft "$SOURCE" -o "${OUTPUT_FORMAT}=${OUTPUT_FILE}" - name: Upload SBOM as artifact if: ${{ inputs.upload-artifact == 'true' }} - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ${{ inputs.artifact-name }} path: ${{ inputs.output-file }} diff --git a/actions/terraform/setup/action.yml b/actions/terraform/setup/action.yml index 8fd1dab..dd044a8 100644 --- a/actions/terraform/setup/action.yml +++ b/actions/terraform/setup/action.yml @@ -25,13 +25,11 @@ runs: BASE_URL="https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}" echo "Downloading Terraform binary and checksums..." - curl -sSL -O "${BASE_URL}/${TERRAFORM_ZIP}" - curl -sSL -O "${BASE_URL}/${TERRAFORM_SHA256SUMS}" + curl -fsSL -O "${BASE_URL}/${TERRAFORM_ZIP}" + curl -fsSL -O "${BASE_URL}/${TERRAFORM_SHA256SUMS}" echo "Verifying checksum..." - grep " ${TERRAFORM_ZIP}$" "${TERRAFORM_SHA256SUMS}" | sha256sum --check --status - - if [ $? -eq 0 ]; then + if grep " ${TERRAFORM_ZIP}$" "${TERRAFORM_SHA256SUMS}" | sha256sum --check --status; then echo "Verification successful!" unzip -o "${TERRAFORM_ZIP}" chmod +x terraform diff --git a/actions/tflint/setup/action.yml b/actions/tflint/setup/action.yml index c60b0aa..1c6ed33 100644 --- a/actions/tflint/setup/action.yml +++ b/actions/tflint/setup/action.yml @@ -24,13 +24,11 @@ runs: BASE_URL="https://github.com/terraform-linters/tflint/releases/download/${TFLINT_VERSION}" echo "Downloading TFLint binary and checksums..." - curl -sSL -O "${BASE_URL}/${TFLINT_ZIP}" - curl -sSL -O "${BASE_URL}/checksums.txt" + curl -fsSL -O "${BASE_URL}/${TFLINT_ZIP}" + curl -fsSL -O "${BASE_URL}/checksums.txt" echo "Verifying checksum..." - grep " ${TFLINT_ZIP}$" checksums.txt | sha256sum --check --status - - if [ $? -eq 0 ]; then + if grep " ${TFLINT_ZIP}$" checksums.txt | sha256sum --check --status; then echo "Verification successful!" unzip -o "${TFLINT_ZIP}" chmod +x tflint diff --git a/actions/trivy/scan/action.yml b/actions/trivy/scan/action.yml index c22edc6..3f65109 100644 --- a/actions/trivy/scan/action.yml +++ b/actions/trivy/scan/action.yml @@ -57,13 +57,11 @@ runs: BASE_URL="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}" echo "Downloading Trivy binary and checksums..." - curl -sSL -O "${BASE_URL}/${TRIVY_TARBALL}" - curl -sSL -O "${BASE_URL}/${TRIVY_CHECKSUMS}" + curl -fsSL -O "${BASE_URL}/${TRIVY_TARBALL}" + curl -fsSL -O "${BASE_URL}/${TRIVY_CHECKSUMS}" echo "Verifying checksum..." - grep " ${TRIVY_TARBALL}$" "${TRIVY_CHECKSUMS}" | sha256sum --check --status - - if [ $? -eq 0 ]; then + if grep " ${TRIVY_TARBALL}$" "${TRIVY_CHECKSUMS}" | sha256sum --check --status; then echo "Verification successful!" tar -xzf "${TRIVY_TARBALL}" trivy chmod +x trivy diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py new file mode 100755 index 0000000..0824ad9 --- /dev/null +++ b/test/check_workflow_parts.py @@ -0,0 +1,415 @@ +#!/usr/bin/env python3 +"""Checks the composite actions and reusable workflows against the conventions of this repository. + +actionlint reads the workflows only, and knows nothing of what this repository promises its callers, +so each check here guards one of those promises against a regression. +Run by test/run_static_checks.sh, which provides PyYAML and shellcheck. +""" + +import fnmatch +import pathlib +import re +import subprocess +import sys +import tempfile + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parent.parent +SELF_REPOSITORY = "devpro/github-workflow-parts" + +# Inputs whose value is shell code or shell words by contract, so they are written into the script as they are. +SHELL_INPUTS = { + "inputs.custom-commands", + "inputs.dotnet-test-args", + "inputs.terraform-var-flags", +} + +# Parts no CI job can exercise, each with the reason, which the coverage check takes as known. +UNTESTED_PARTS = { + "actions/dotnet/build-test-sonar": "needs a Sonar token", + "actions/fossa/analyze": "needs a FOSSA API key", + "actions/mongodb-atlas/add-runner-ip": "needs MongoDB Atlas credentials", + ".github/workflows/reusable-container-publication.yml": "needs registry credentials and an OIDC token to sign", + ".github/workflows/reusable-terraform-deployment.yml": "needs MongoDB Atlas credentials and a Terraform backend", +} + +# Inputs nothing reads, kept since a caller passing an input a reusable workflow does not declare fails. +RESERVED_INPUTS = { + ".github/workflows/reusable-container-scan.yml": {"max-high-cves", "max-medium-cves", "neuvector-enabled"}, +} + +TEXT_SUFFIXES = {".cs", ".csproj", ".hcl", ".json", ".md", ".py", ".sh", ".tf", ".yaml", ".yml", ""} +DASHES = (chr(0x2013), chr(0x2014)) +SECOND_PERSON = re.compile(r"\b(you|your|yours|yourself)\b", re.IGNORECASE) +EXPRESSION = re.compile(r"\$\{\{\s*(.*?)\s*\}\}") +UNSAFE_IN_RUN = re.compile(r"^(inputs|secrets|env|steps|vars|matrix|needs|github\.event|github\.head_ref)\b") +INPUT_REFERENCE = re.compile(r"\binputs\.([A-Za-z0-9_-]+)") +SECRET_REFERENCE = re.compile(r"\bsecrets\.([A-Za-z0-9_-]+)") +ACTION_REFERENCE = re.compile(r"^actions/[a-z0-9-]+@[0-9a-f]{40}$") +# A tag can be moved to another commit, a commit cannot, and the comment tells which release it is. +PINNED_LINE = re.compile(r"uses: actions/[a-z0-9-]+@[0-9a-f]{40} # v\d+\.\d+\.\d+$") + +failures = [] + + +def fail(path, message): + failures.append(f"{path}: {message}") + + +def relative(path): + return path.relative_to(ROOT).as_posix() + + +def load(path): + # The base loader keeps every scalar a string, so the "on" key of a workflow is not read as true. + return yaml.load(path.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +def git(*arguments): + return subprocess.run(["git", *arguments], cwd=ROOT, check=True, capture_output=True, text=True).stdout + + +def tracked_files(): + """Maps the files of the working tree git does not ignore to their mode, the one of the index when they are staged.""" + modes = {} + for line in git("ls-files", "-s").splitlines(): + meta, name = line.split("\t", 1) + modes[name] = meta.split()[0] + files = {} + for name in git("ls-files", "--cached", "--others", "--exclude-standard").splitlines(): + path = ROOT / name + if path.is_file(): + files[name] = modes.get(name, "100755" if path.stat().st_mode & 0o100 else "100644") + return files + + +def action_paths(): + return sorted(path.parent for path in (ROOT / "actions").glob("*/*/action.yml")) + + +def workflow_paths(): + return sorted((ROOT / ".github/workflows").glob("*.yml")) + + +def steps_of(document): + """Yields every step of a composite action or of the jobs of a workflow.""" + if "runs" in document: + yield from document["runs"].get("steps", []) + for job in (document.get("jobs") or {}).values(): + yield from job.get("steps", []) + + +def check_text_hygiene(files): + for name, mode in files.items(): + path = ROOT / name + if path.suffix not in TEXT_SUFFIXES or path.name == "LICENSE": + continue + raw = path.read_bytes() + if raw.startswith(b"\xef\xbb\xbf"): + fail(name, "starts with a byte order mark") + if b"\r\n" in raw: + fail(name, "has CRLF line endings, where .editorconfig asks for LF") + if raw and not raw.endswith(b"\n"): + fail(name, "does not end with a new line") + text = raw.decode("utf-8") + for number, line in enumerate(text.splitlines(), 1): + if any(dash in line for dash in DASHES): + fail(f"{name}:{number}", "holds an en or em dash, which the writing style replaces with a colon, a comma or a full stop") + # A quoted source keeps its own words. + if path.suffix in {".md", ".yml", ".yaml"} and name not in {"AGENTS.md", "CLAUDE.md"} and not line.lstrip().startswith(">"): + if SECOND_PERSON.search(line): + fail(f"{name}:{number}", "speaks in the second person") + if path.suffix in {".sh", ".py"}: + if not re.fullmatch(r"[a-z0-9_]+", path.stem): + fail(name, "script name is not snake_case") + if mode != "100755": + fail(name, "script is committed without the executable bit") + if path.suffix in {".yml", ".yaml"}: + try: + load(path) + except yaml.YAMLError as error: + fail(name, f"is not valid YAML: {error}") + + +def check_inputs_are_used(name, text, declared, kind, pattern): + # A commented out step reads nothing. + text = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#")) + used = set(pattern.findall(text)) + reserved = RESERVED_INPUTS.get(name, set()) if kind == "input" else set() + for missing in sorted(used - set(declared)): + fail(name, f"references the {kind} '{missing}', which is not declared") + for unused in sorted(set(declared) - used - reserved): + fail(name, f"declares the {kind} '{unused}', which nothing reads") + for stale in sorted(reserved & used): + fail(name, f"lists the input '{stale}' in RESERVED_INPUTS, but reads it") + + +def check_action(directory): + path = directory / "action.yml" + name = relative(path) + document = load(path) + for key in ("name", "description"): + if not str(document.get(key, "")).strip(): + fail(name, f"has no {key}") + runs = document.get("runs") or {} + if runs.get("using") != "composite": + fail(name, "is not a composite action") + inputs = document.get("inputs") or {} + for input_name, spec in inputs.items(): + if not str(spec.get("description", "")).strip(): + fail(name, f"input '{input_name}' has no description") + if spec.get("required") == "true" and "default" in spec: + fail(name, f"input '{input_name}' is required and has a default, which GitHub never uses") + check_inputs_are_used(name, path.read_text(encoding="utf-8"), inputs, "input", INPUT_REFERENCE) + for index, step in enumerate(runs.get("steps", []), 1): + if not step.get("name"): + fail(name, f"step {index} has no name") + if "run" in step and step.get("shell") != "bash": + fail(name, f"step '{step.get('name', index)}' runs a script without 'shell: bash', which a composite action requires and the actions assume") + + +def check_reusable_workflow(path): + name = relative(path) + document = load(path) + if not str(document.get("name", "")).startswith("Reusable - "): + fail(name, "name does not start with 'Reusable - '") + call = (document.get("on") or {}).get("workflow_call") + if call is None: + fail(name, "is not triggered by workflow_call") + return + call = call or {} + text = path.read_text(encoding="utf-8") + inputs = call.get("inputs") or {} + for input_name, spec in inputs.items(): + if not str(spec.get("description", "")).strip(): + fail(name, f"input '{input_name}' has no description") + if not spec.get("type"): + fail(name, f"input '{input_name}' has no type") + check_inputs_are_used(name, text, inputs, "input", INPUT_REFERENCE) + check_inputs_are_used(name, text, call.get("secrets") or {}, "secret", SECRET_REFERENCE) + for step in steps_of(document): + if step.get("with", {}).get("repository") == SELF_REPOSITORY: + # Without it, the CI of this repository would test the workflow parts of the default branch, not those of the commit. + expected = f"github.repository == '{SELF_REPOSITORY}' && github.sha || inputs.workflow-parts-version" + if expected not in step["with"].get("ref", ""): + fail(name, f"checks out the workflow parts without '{expected}' as ref") + + +def check_run_scripts(path, document): + name = relative(path) + for step in steps_of(document): + script = step.get("run") + if script is None: + continue + label = f"step '{step.get('name', '?')}'" + for expression in EXPRESSION.findall(script): + if UNSAFE_IN_RUN.match(expression) and expression not in SHELL_INPUTS: + fail(name, f"{label} writes '${{{{ {expression} }}}}' into its script, where an env variable keeps the value from being run as code") + if re.search(r"\$\?\s+-(eq|ne)", script): + fail(name, f"{label} tests $? after a command, which bash -e never reaches when the command fails") + # A comment runs nothing, even when it names a command. + code = "\n".join(line for line in script.splitlines() if not line.lstrip().startswith("#")) + joined = code.replace("\\\n", " ") + for command in re.findall(r"\bcurl\b[^\n|;&)]*", joined): + flags = " ".join(token for token in command.split() if token.startswith("-")) + if not re.search(r"(^|\s)(-[a-zA-Z]*[fw][a-zA-Z]*|--fail|--write-out)\b", flags): + fail(name, f"{label} calls curl without --fail nor --write-out, which saves an HTTP error page as the download") + if "curl" in code and " -O " in joined and "sha256sum --check" not in script: + fail(name, f"{label} downloads a file without verifying its SHA256 checksum") + + +def shellcheck_actions(): + with tempfile.TemporaryDirectory() as directory: + scripts = {} + for action in action_paths(): + document = load(action / "action.yml") + for index, step in enumerate(document["runs"].get("steps", []), 1): + if "run" not in step: + continue + script = pathlib.Path(directory) / f"{action.relative_to(ROOT).as_posix().replace('/', '_')}_{index}.sh" + # An expression becomes a word shellcheck reads as a plain value, as the runner writes it before bash runs. + body = EXPRESSION.sub("expression_value", step["run"]) + script.write_text(f"#!/usr/bin/env bash\nset -eo pipefail\n{body}\n", encoding="utf-8") + scripts[script.name] = f"{relative(action / 'action.yml')} step '{step.get('name', index)}'" + if not scripts: + return + result = subprocess.run( + ["shellcheck", "--shell=bash", "--format=gcc", *sorted(scripts)], + cwd=directory, + capture_output=True, + text=True, + ) + for line in result.stdout.splitlines(): + script, _, message = line.partition(":") + fail(scripts.get(script, script), f"shellcheck line {message.strip()}") + if result.returncode not in (0, 1): + fail("shellcheck", result.stderr.strip()) + + +def local_target(uses): + """Maps a local reference to the action or workflow of this repository it names.""" + for prefix in ("./workflow-parts/", "./"): + if uses.startswith(prefix): + return uses[len(prefix):].rstrip("/") + return None + + +def check_references(path, document): + name = relative(path) + for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): + if "uses: actions/" in line and not PINNED_LINE.search(line): + fail(f"{name}:{number}", "pins a first party action otherwise than to a full commit SHA followed by '# vX.Y.Z'") + references = [step for step in steps_of(document) if "uses" in step] + references += [job for job in (document.get("jobs") or {}).values() if "uses" in job] + for item in references: + uses = item["uses"] + target = local_target(uses) + if target is None: + # First party actions only, the rest being downloaded with a checksum by the actions of this repository. + if not ACTION_REFERENCE.match(uses): + fail(name, f"uses '{uses}', which is neither local nor a first party action pinned to a commit SHA") + continue + target_path = ROOT / target + given = set((item.get("with") or {}).keys()) + if target.endswith(".yml"): + if not target_path.is_file(): + fail(name, f"calls '{uses}', which does not exist") + continue + declared = (load(target_path)["on"]["workflow_call"] or {}).get("inputs") or {} + else: + if not (target_path / "action.yml").is_file(): + fail(name, f"uses '{uses}', which does not exist") + continue + declared = load(target_path / "action.yml").get("inputs") or {} + for unknown in sorted(given - set(declared)): + fail(name, f"passes '{unknown}' to '{uses}', which does not declare it") + for input_name, spec in declared.items(): + if spec.get("required") == "true" and "default" not in spec and input_name not in given: + fail(name, f"does not pass the required input '{input_name}' to '{uses}'") + + +def check_action_versions(): + versions = {} + for path in [*workflow_paths(), *(action / "action.yml" for action in action_paths())]: + for step in steps_of(load(path)): + uses = step.get("uses", "") + if ACTION_REFERENCE.match(uses): + action, version = uses.split("@") + versions.setdefault(action, {}).setdefault(version, []).append(relative(path)) + for action, by_version in versions.items(): + if len(by_version) > 1: + fail(action, f"is pinned to several versions: {', '.join(f'{v} in {sorted(set(f))}' for v, f in sorted(by_version.items()))}") + + +def check_readme(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + links = set(re.findall(r"\]\(([^)#]+)\)", readme)) + for link in sorted(links): + if not link.startswith("http") and not (ROOT / link).exists(): + fail("README.md", f"links to '{link}', which does not exist") + linked = {link.rstrip("/").removesuffix("/action.yml") for link in links} + parts = [relative(action) for action in action_paths()] + parts += [relative(path) for path in workflow_paths() if path.name.startswith("reusable-")] + for part in parts: + if part not in linked: + fail("README.md", f"does not list '{part}'") + + +def runs_with(condition, values): + """Tells whether a condition holds for the given inputs, None when it reads anything else than an input.""" + condition = condition.strip() + if condition.startswith("${{") and condition.endswith("}}"): + condition = condition[3:-2].strip() + match = re.fullmatch(r"(!\s*)?inputs\.([A-Za-z0-9_-]+)(?:\s*(==|!=)\s*'([^']*)')?", condition) + if not match or match.group(2) not in values or "${{" in values[match.group(2)]: + return None + negation, name, operator, expected = match.groups() + value = values[name] + if operator: + return (value == expected) == (operator == "==") + return (value not in ("", "false", "0")) != bool(negation) + + +def declared_inputs(path): + document = load(path) + if "runs" in document: + return document.get("inputs") or {} + return (document["on"]["workflow_call"] or {}).get("inputs") or {} + + +def exercised_parts(): + """Collects what the CI workflow runs, directly or through the steps of what it calls that its inputs let run.""" + exercised = set() + pending = [(ROOT / ".github/workflows/ci.yml", None)] + while pending: + path, values = pending.pop() + document = load(path) + items = [job for job in (document.get("jobs") or {}).values() if "uses" in job] + items += [step for step in steps_of(document) if "uses" in step] + for item in items: + target = local_target(item["uses"]) + if target is None: + continue + # A step of the CI itself runs as written, and a step of a called part only when the inputs it is called with let it. + if values is not None and "if" in item and not runs_with(item["if"], values): + continue + target_path = ROOT / target if target.endswith(".yml") else ROOT / target / "action.yml" + called_with = {name: spec.get("default", "") for name, spec in declared_inputs(target_path).items()} + called_with.update(item.get("with") or {}) + exercised.add(target) + pending.append((target_path, called_with)) + return exercised + + +def check_coverage(): + exercised = exercised_parts() + parts = [relative(action) for action in action_paths()] + parts += [relative(path) for path in workflow_paths() if path.name.startswith("reusable-")] + for part in parts: + if part in exercised and part in UNTESTED_PARTS: + fail(part, "is exercised by the CI, so it no longer belongs to UNTESTED_PARTS") + elif part not in exercised and part not in UNTESTED_PARTS: + fail(part, "is exercised by no CI job, nor listed in UNTESTED_PARTS with the reason") + for part in sorted(set(UNTESTED_PARTS) - set(parts)): + fail(part, "is listed in UNTESTED_PARTS, but does not exist") + + +def check_istarci(): + path = ROOT / ".istarci.yml" + if not path.exists(): + return + runner = load(path).get("runner") or {} + jobs = list((load(ROOT / ".github/workflows/ci.yml").get("jobs") or {}).keys()) + patterns = list((runner.get("images") or {}).keys()) + list(runner.get("docker") or []) + for pattern in patterns: + if not fnmatch.filter(jobs, pattern): + fail(".istarci.yml", f"pattern '{pattern}' matches no job of the CI workflow") + + +def main(): + check_text_hygiene(tracked_files()) + for action in action_paths(): + check_action(action) + document = load(action / "action.yml") + check_run_scripts(action / "action.yml", document) + check_references(action / "action.yml", document) + for path in workflow_paths(): + document = load(path) + if path.name.startswith("reusable-"): + check_reusable_workflow(path) + check_run_scripts(path, document) + check_references(path, document) + shellcheck_actions() + check_action_versions() + check_readme() + check_coverage() + check_istarci() + for failure in failures: + print(f"FAIL {failure}") + print(f"{len(failures)} failure(s)") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/fixtures/dotnet-library/.editorconfig b/test/fixtures/dotnet-library/.editorconfig new file mode 100644 index 0000000..78de46e --- /dev/null +++ b/test/fixtures/dotnet-library/.editorconfig @@ -0,0 +1,11 @@ +# C# is indented by four, where the rest of this repository is indented by two. +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true + +[*.cs] +indent_style = space +indent_size = 4 diff --git a/test/fixtures/dotnet-library/.gitignore b/test/fixtures/dotnet-library/.gitignore new file mode 100644 index 0000000..3e4ab67 --- /dev/null +++ b/test/fixtures/dotnet-library/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +output/ diff --git a/test/fixtures/dotnet-library/Fixture.Library.csproj b/test/fixtures/dotnet-library/Fixture.Library.csproj new file mode 100644 index 0000000..64bdfe4 --- /dev/null +++ b/test/fixtures/dotnet-library/Fixture.Library.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + enable + enable + Fixture.Library + 1.0.0 + + + diff --git a/test/fixtures/dotnet-library/Greeting.cs b/test/fixtures/dotnet-library/Greeting.cs new file mode 100644 index 0000000..5aaecdf --- /dev/null +++ b/test/fixtures/dotnet-library/Greeting.cs @@ -0,0 +1,7 @@ +namespace Fixture.Library; + +// Packaged by the CI, which checks that the pack action finds the project in its working directory. +public static class Greeting +{ + public static string Hello() => "Hello"; +} diff --git a/test/requirements.txt b/test/requirements.txt new file mode 100644 index 0000000..8f635d9 --- /dev/null +++ b/test/requirements.txt @@ -0,0 +1,81 @@ +# Tools of test/run_static_checks.sh, pinned with the hashes of every published file, as pip --require-hashes asks. +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 +shellcheck-py==0.11.0.1 \ + --hash=sha256:1b274df81de5b000ff78db433e7328b87e52e3c38481c60f8e488c3095beef05 \ + --hash=sha256:5c620c88901e8f1d3be5934b31ea99e3310065e1245253741eafd0a275c8c9cc \ + --hash=sha256:6b88d0a244c82ed07e06a53e444da841f69330ca59ae15d4a66c391655dae7a0 \ + --hash=sha256:784156289ecb17e91c692cd783ab5152333309588cabb10032a047331c63e759 \ + --hash=sha256:b6a3fee28efda2e16e38d6e6d59faf7224300256456639727370d404730849e8 diff --git a/test/run_static_checks.sh b/test/run_static_checks.sh new file mode 100755 index 0000000..b2add31 --- /dev/null +++ b/test/run_static_checks.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Runs the static checks of the workflow parts, the same way on a workstation and in the CI. +# The tools are pinned and verified like the binaries the actions download, and kept between two local runs. +# Linux x64 only. + +set -euo pipefail + +ACTIONLINT_VERSION=1.7.12 + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tools_dir="${RUNNER_TEMP:-${XDG_CACHE_HOME:-$HOME/.cache}}/github-workflow-parts-checks" +requirements="${repo_root}/test/requirements.txt" +requirements_hash="$(sha256sum "$requirements" | cut -d ' ' -f 1)" + +# The virtual environment is built again whenever the requirements change. +if [ ! -f "${tools_dir}/venv/${requirements_hash}" ]; then + echo "Installing PyYAML and shellcheck..." + rm -rf "${tools_dir}/venv" + python3 -m venv "${tools_dir}/venv" + "${tools_dir}/venv/bin/pip" install --quiet --disable-pip-version-check --require-hashes --only-binary :all: -r "$requirements" + touch "${tools_dir}/venv/${requirements_hash}" +fi + +actionlint="${tools_dir}/actionlint-${ACTIONLINT_VERSION}" +if [ ! -x "$actionlint" ]; then + echo "Installing actionlint ${ACTIONLINT_VERSION}..." + download_dir="$(mktemp -d)" + tarball="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + base_url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}" + curl -fsSL -o "${download_dir}/${tarball}" "${base_url}/${tarball}" + curl -fsSL -o "${download_dir}/checksums.txt" "${base_url}/actionlint_${ACTIONLINT_VERSION}_checksums.txt" + if ! (cd "$download_dir" && grep " ${tarball}$" checksums.txt | sha256sum --check --status); then + echo "ERROR: Checksum verification failed for ${tarball}" >&2 + exit 1 + fi + tar -xzf "${download_dir}/${tarball}" -C "$download_dir" actionlint + mkdir -p "$tools_dir" + mv "${download_dir}/actionlint" "$actionlint" + rm -rf "$download_dir" +fi + +export PATH="${tools_dir}/venv/bin:$PATH" +cd "$repo_root" + +status=0 +echo "Running actionlint on the workflows..." +"$actionlint" -shellcheck "$(command -v shellcheck)" || status=1 +echo "Checking the conventions of the actions and workflows..." +python3 test/check_workflow_parts.py || status=1 +exit "$status" From 483970d0b1e9301a9ba8319021cd76a1f86b0189 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 13:16:48 +0200 Subject: [PATCH 17/55] Compare the installed versions once captured, and tell agents that IstarCI runs each commit by itself A reader closing the pipe early failed the version check with SIGPIPE under pipefail. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/ci.yml | 7 ++++--- AGENTS.md | 2 ++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 043348a..02dc76f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,9 +74,10 @@ jobs: helm-version: v3.16.4 - name: Verify the versions run: | - terraform version | head -1 | grep -qx 'Terraform v1.9.8' - tflint --version | grep -q 'version 0.53.0' - helm version --short | grep -q '^v3.16.4+' + # Compared once captured, since a reader closing the pipe early fails the step with SIGPIPE under pipefail. + [[ "$(terraform version)" == "Terraform v1.9.8"* ]] + [[ "$(tflint --version)" == *"version 0.53.0"* ]] + [[ "$(helm version --short)" == v3.16.4+* ]] # A setup action that cannot verify what it downloaded stops the job rather than running it. test-setup-fails-closed: diff --git a/AGENTS.md b/AGENTS.md index 93e9423..8f1a556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,8 @@ Linux or WSL2, Docker and `bash`. No background commands, no subagents, no forks, no parallel tasks, even for a long commands. - Headers stay: short documentation still has sections. - Linters for YAML and Markdown are never run by an agent. +- The IstarCI daemon runs the pipeline on every commit by itself, so an agent never starts a run with `istarci run`. + `istarci wait` waits for the run of `HEAD` and prints the log of each job that failed, and the `pre-push` hook gates a push on it. - Commit only when asked, and never push. Shell scripts are `snake_case` and committed with the executable bit (`git update-index --chmod=+x`). - Documentation is as short as possible. From 433fe62f1f9ae2ab70e7805fad2fce1193417a51 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 14:25:38 +0200 Subject: [PATCH 18/55] Move the downloaded tools to their latest release, and pin the commented out actions to a commit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/reusable-container-scan.yml | 2 +- .github/workflows/reusable-terraform-quality.yml | 6 +++--- actions/chart-testing/setup/action.yml | 4 ++-- actions/cosign/sign/action.yml | 2 +- actions/fossa/analyze/action.yml | 2 +- actions/goreleaser/setup/action.yml | 2 +- actions/helm/setup/action.yml | 2 +- actions/mongodb/start/action.yml | 2 +- actions/syft/generate-sbom/action.yml | 2 +- actions/terraform/setup/action.yml | 2 +- actions/tflint/setup/action.yml | 2 +- actions/trivy/scan/action.yml | 2 +- 12 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index 183b460..b2f079a 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -113,7 +113,7 @@ jobs: # deactivated 2026-02-22: "Build container for action use... process "/bin/sh -c zypper in -y jq docker && zypper clean" did not complete successfully: exit code: 8" # - name: Scan container image with NeuVector # if: ${{ inputs.neuvector-enabled }} - # uses: neuvector/scan-action@main + # uses: neuvector/scan-action@196964fb269cfcd1fe139dd27c8440a8396bd8ab # 1.0.0 # with: # image-repository: ${{ inputs.image-path }}/${{ inputs.image-name }} # image-tag: ${{ inputs.image-tag }} diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index e7e30e7..f21cd84 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -58,14 +58,14 @@ jobs: # Checkov is a static code analysis tool for infrastructure as code (IaC) and also a software composition analysis (SCA) tool for images and open source packages - name: Run Checkov run: | - pipx run --spec checkov==3.3.21 checkov -d . \ + pipx run --spec checkov==3.3.29 checkov -d . \ --soft-fail \ --output cli --output sarif \ --output-file-path console,results.sarif continue-on-error: true # needs GitHub code security > code scanning, not available on private repos # - name: Upload SARIF file - # uses: github/codeql-action/upload-sarif@v3 + # uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 # if: success() || failure() # with: # sarif_file: results.sarif @@ -79,7 +79,7 @@ jobs: uses: ./workflow-parts/actions/tflint/setup with: # ref. https://github.com/terraform-linters/tflint/releases - tflint-version: v0.60.0 + tflint-version: v0.64.0 - name: Initialize TFLint run: tflint --init --recursive env: diff --git a/actions/chart-testing/setup/action.yml b/actions/chart-testing/setup/action.yml index 5babac7..1639191 100644 --- a/actions/chart-testing/setup/action.yml +++ b/actions/chart-testing/setup/action.yml @@ -13,11 +13,11 @@ inputs: yamllint-version: description: Version of yamllint to install required: false - default: "1.37.1" + default: "1.38.0" yamale-version: description: Version of yamale to install required: false - default: "6.0.0" + default: "6.1.0" runs: using: "composite" diff --git a/actions/cosign/sign/action.yml b/actions/cosign/sign/action.yml index 844359c..7bcecd0 100644 --- a/actions/cosign/sign/action.yml +++ b/actions/cosign/sign/action.yml @@ -11,7 +11,7 @@ inputs: cosign-version: description: Version of cosign (check latest from https://github.com/sigstore/cosign/releases) required: false - default: 'v3.0.6' + default: 'v3.1.3' image-name: description: Image name required: true diff --git a/actions/fossa/analyze/action.yml b/actions/fossa/analyze/action.yml index fd3e430..d0124a4 100644 --- a/actions/fossa/analyze/action.yml +++ b/actions/fossa/analyze/action.yml @@ -9,7 +9,7 @@ inputs: fossa-version: description: Version of the FOSSA CLI to install (check latest from https://github.com/fossas/fossa-cli/releases) required: false - default: "3.19.3" + default: "3.20.4" api-key: description: FOSSA API key required: true diff --git a/actions/goreleaser/setup/action.yml b/actions/goreleaser/setup/action.yml index 792556a..6828f50 100644 --- a/actions/goreleaser/setup/action.yml +++ b/actions/goreleaser/setup/action.yml @@ -8,7 +8,7 @@ inputs: goreleaser-version: description: Version of GoReleaser to install (check latest from https://github.com/goreleaser/goreleaser/releases) required: false - default: "2.18.2" + default: "2.18.3" runs: using: "composite" diff --git a/actions/helm/setup/action.yml b/actions/helm/setup/action.yml index c988d30..cc218e4 100644 --- a/actions/helm/setup/action.yml +++ b/actions/helm/setup/action.yml @@ -8,7 +8,7 @@ inputs: helm-version: description: Version of Helm to install (check latest from https://github.com/helm/helm/releases) required: false - default: "v4.3.0" + default: "v4.3.1" runs: using: "composite" diff --git a/actions/mongodb/start/action.yml b/actions/mongodb/start/action.yml index dae09de..0153ea5 100644 --- a/actions/mongodb/start/action.yml +++ b/actions/mongodb/start/action.yml @@ -9,7 +9,7 @@ inputs: mongodb-version: description: Version of the MongoDB server (check latest from https://www.mongodb.com/try/download/community) required: false - default: "8.2.12" + default: "9.0.4" mongosh-version: description: Version of mongosh (check latest from https://github.com/mongodb-js/mongosh/releases) required: false diff --git a/actions/syft/generate-sbom/action.yml b/actions/syft/generate-sbom/action.yml index 7ffb258..8475850 100644 --- a/actions/syft/generate-sbom/action.yml +++ b/actions/syft/generate-sbom/action.yml @@ -9,7 +9,7 @@ inputs: syft-version: description: Version of Syft to install (check latest from https://github.com/anchore/syft/releases) required: false - default: "1.49.0" + default: "1.54.1" target-image: description: Container image reference to scan (mutually exclusive with target-path) required: false diff --git a/actions/terraform/setup/action.yml b/actions/terraform/setup/action.yml index dd044a8..1a8894a 100644 --- a/actions/terraform/setup/action.yml +++ b/actions/terraform/setup/action.yml @@ -8,7 +8,7 @@ inputs: terraform-version: description: Version of Terraform to install (check latest from https://releases.hashicorp.com/terraform/) required: false - default: "1.15.8" + default: "1.16.6" runs: using: "composite" diff --git a/actions/tflint/setup/action.yml b/actions/tflint/setup/action.yml index 1c6ed33..f4f5cb5 100644 --- a/actions/tflint/setup/action.yml +++ b/actions/tflint/setup/action.yml @@ -8,7 +8,7 @@ inputs: tflint-version: description: Version of TFLint to install (check latest from https://github.com/terraform-linters/tflint/releases) required: false - default: "v0.60.0" + default: "v0.64.0" runs: using: "composite" diff --git a/actions/trivy/scan/action.yml b/actions/trivy/scan/action.yml index 3f65109..4a3124d 100644 --- a/actions/trivy/scan/action.yml +++ b/actions/trivy/scan/action.yml @@ -9,7 +9,7 @@ inputs: trivy-version: description: Version of Trivy to install (check latest from https://github.com/aquasecurity/trivy/releases) required: false - default: "0.72.0" + default: "0.76.0" scan-type: description: What to scan, "image" (container image reference) or "config" (misconfiguration/IaC scan of a directory) required: false From 4ac71c9c4cdf548db999b343a0d3923861918041 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 15:01:24 +0200 Subject: [PATCH 19/55] Emulate other platforms with QEMU from the Ubuntu archive, pin the .NET tools, upload the .NET reports, and give every job a timeout The publication workflow runs no tonistiigi/binfmt image, which a setup action replaces with the qemu-user-static package the runner's distribution signs. ReportGenerator and the Sonar scanner are installed at a version, which keys the cache of the scanner. The .NET quality workflow uploads the report folder of its working directory, where the results, the coverage and the reports are. The Sonar path runs against the SonarQube IstarCI provides, and is skipped where no Sonar host is set. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/ci.yml | 70 +++++++++++++++++++ .../reusable-container-publication.yml | 10 ++- .github/workflows/reusable-container-scan.yml | 6 ++ .github/workflows/reusable-dotnet-quality.yml | 11 ++- .github/workflows/reusable-markup-lint.yml | 6 ++ .../reusable-terraform-deployment.yml | 6 ++ .../workflows/reusable-terraform-quality.yml | 6 ++ .istarci.yml | 6 ++ README.md | 1 + actions/dotnet/build-test-sonar/action.yml | 40 +++++++---- .../dotnet/install-lint-restore/action.yml | 8 ++- actions/qemu/setup/action.yml | 48 +++++++++++++ test/check_workflow_parts.py | 13 +++- test/fixtures/dotnet/Fixture.Tests.csproj | 1 + 14 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 actions/qemu/setup/action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 02dc76f..2431180 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,7 @@ jobs: static-checks: name: "Test / Static checks of the actions and workflows" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -31,6 +32,7 @@ jobs: test-terraform-setup: name: "Test / Terraform setup action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -44,6 +46,7 @@ jobs: test-tflint-setup: name: "Test / TFLint setup action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -57,6 +60,7 @@ jobs: test-setup-versions: name: "Test / Setup actions install the version asked for" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -83,6 +87,7 @@ jobs: test-setup-fails-closed: name: "Test / Setup actions fail on a missing release or a wrong checksum" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -129,6 +134,7 @@ jobs: test-helm-setup: name: "Test / Helm setup action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -142,6 +148,7 @@ jobs: test-chart-testing-setup: name: "Test / chart-testing setup action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -157,6 +164,7 @@ jobs: test-goreleaser-setup: name: "Test / GoReleaser setup action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -170,6 +178,7 @@ jobs: test-cosign-sign: name: "Test / Cosign sign action, without signing" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -203,6 +212,7 @@ jobs: test-dotnet-pack-push: name: "Test / .NET pack-push action, without pushing" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -214,9 +224,25 @@ jobs: - name: Verify the package was created run: test -s test/fixtures/dotnet-library/output/Fixture.Library.1.0.0.nupkg + test-qemu-setup: + name: "Test / QEMU setup action runs an arm64 image" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup QEMU + uses: ./actions/qemu/setup + with: + platforms: linux/amd64,linux/arm64,linux/arm/v7 + - name: Verify an arm64 image runs + run: | + [ "$(docker run --rm --platform linux/arm64 busybox:1.37 uname -m)" = aarch64 ] + test-mongodb-start: name: "Test / MongoDB start action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -229,6 +255,7 @@ jobs: test-syft-generate-sbom: name: "Test / Syft generate-sbom action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -254,6 +281,7 @@ jobs: test-trivy-scan: name: "Test / Trivy scan action" runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -302,6 +330,48 @@ jobs: working-directory: test/fixtures/dotnet # The fixture holds a test that fails, which only the filter keeps the job from running. dotnet-test-filter: --filter-not-namespace Fixture.Tests.LeftOut* + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura + + # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. + test-dotnet-quality-sonar: + name: "Test / .NET quality reusable workflow with Sonar" + if: vars.SONAR_HOST_URL != '' + uses: ./.github/workflows/reusable-dotnet-quality.yml + with: + working-directory: test/fixtures/dotnet + dotnet-test-filter: --filter-not-namespace Fixture.Tests.LeftOut* + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura + sonar-enabled: true + sonar-project-key: github-workflow-parts-fixture + sonar-project-name: GitHub workflow parts fixture + secrets: + sonar-token: ${{ secrets.SONAR_TOKEN }} + + test-dotnet-build-test: + name: "Test / .NET build-test action writes results, coverage and reports" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install .NET & lint + uses: ./actions/dotnet/install-lint-restore + with: + working-directory: test/fixtures/dotnet + - name: Build & test + uses: ./actions/dotnet/build-test + with: + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace Fixture.Tests.LeftOut* + working-directory: test/fixtures/dotnet + # The paths are those the .NET quality workflow uploads as its artifact. + - name: Verify the results, the coverage and the reports + working-directory: test/fixtures/dotnet/report + run: | + compgen -G "TestResults/*.trx" >/dev/null + compgen -G "TestResults/*.cobertura.xml" >/dev/null + test -s Summary.txt + test -s index.html + grep -q 'Line coverage' Summary.txt test-container-scan-on-base: name: "Test / Container scan reusable workflow on a base built in the run" diff --git a/.github/workflows/reusable-container-publication.yml b/.github/workflows/reusable-container-publication.yml index 00f42db..b50dd3c 100644 --- a/.github/workflows/reusable-container-publication.yml +++ b/.github/workflows/reusable-container-publication.yml @@ -52,6 +52,11 @@ on: type: string required: false default: Publication + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 60 operating-system: description: Operating system executing the runner type: string @@ -82,6 +87,7 @@ jobs: container-publication: name: ${{ inputs.job-name }} runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} defaults: run: working-directory: ${{ inputs.working-directory }} @@ -118,7 +124,9 @@ jobs: - name: Set up QEMU # Emulation is only needed for a platform other than the runner's own. if: ${{ inputs.image-platform != 'linux/amd64' }} - run: docker run --privileged --rm tonistiigi/binfmt --install all + uses: ./workflow-parts/actions/qemu/setup + with: + platforms: ${{ inputs.image-platform }} - name: Set up Buildx run: docker buildx create --use --driver docker-container - name: Build and push container image diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index b2f079a..35627cf 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -34,6 +34,11 @@ on: type: string required: false default: Scan + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 30 max-high-cves: description: Maximum number of high CVEs authorized type: number @@ -74,6 +79,7 @@ jobs: container-scan: name: ${{ inputs.job-name }} runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} defaults: run: working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index b082193..b7b79b5 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -48,6 +48,11 @@ on: type: string required: false default: Quality + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 60 operating-system: description: Operating system executing the runner type: string @@ -113,6 +118,7 @@ jobs: dotnet-quality: name: ${{ inputs.job-name }} runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} defaults: run: working-directory: ${{ inputs.working-directory }} @@ -204,12 +210,11 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dotnet-test-results + # The build and test actions write the results, the coverage and the reports in the report folder of the working directory. path: | + ${{ inputs.working-directory }}/report/ ./**/*test-result.xml - ./test/*/TestResults/*/coverage.cobertura.xml ./**/failure_*.png* - ./**/SonarQube.xml - ./**/Summary.txt ./**/fossa.html env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 diff --git a/.github/workflows/reusable-markup-lint.yml b/.github/workflows/reusable-markup-lint.yml index 7868ae2..68db049 100644 --- a/.github/workflows/reusable-markup-lint.yml +++ b/.github/workflows/reusable-markup-lint.yml @@ -8,6 +8,11 @@ on: type: string required: false default: "Lint" + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 10 operating-system: description: "Operating system executing the runner" type: string @@ -23,6 +28,7 @@ jobs: markup-lint: name: ${{ inputs.job-name }} runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} defaults: run: working-directory: ${{ inputs.working-directory }} diff --git a/.github/workflows/reusable-terraform-deployment.yml b/.github/workflows/reusable-terraform-deployment.yml index 4359995..fb79f69 100644 --- a/.github/workflows/reusable-terraform-deployment.yml +++ b/.github/workflows/reusable-terraform-deployment.yml @@ -8,6 +8,11 @@ on: type: string required: false default: "Deploy" + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 60 working-directory: description: "Working directory" type: string @@ -74,6 +79,7 @@ jobs: terraform-apply: name: ${{ inputs.job-name }} runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} environment: ${{ inputs.environment }} defaults: run: diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index f21cd84..a98ebf3 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -8,6 +8,11 @@ on: type: string required: false default: "Validate" + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 30 working-directory: description: "Working directory" type: string @@ -28,6 +33,7 @@ jobs: terraform-validate: name: ${{ inputs.job-name }} runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} defaults: run: working-directory: ${{ inputs.working-directory }} diff --git a/.istarci.yml b/.istarci.yml index 112e355..e05fcb8 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -6,3 +6,9 @@ runner: "test-dotnet-*": ghcr.io/devpro/ubuntu-dotnet:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*"] + # The Sonar path of the .NET quality workflow runs against a SonarQube of this machine, which GitHub does not have. + sonarqube: true + +workflow: + # Registering an emulator changes the kernel of the machine, which a local run leaves alone. + skipJobs: [test-qemu-setup] diff --git a/README.md b/README.md index 34a247d..16a5b0c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Terraform: Containers: - [Sign an image with Cosign](actions/cosign/sign/action.yml) +- [Setup QEMU to build images for other platforms](actions/qemu/setup/action.yml) - [Scan an image or a directory with Trivy](actions/trivy/scan/action.yml) .NET: diff --git a/actions/dotnet/build-test-sonar/action.yml b/actions/dotnet/build-test-sonar/action.yml index c17e3e2..9befddb 100644 --- a/actions/dotnet/build-test-sonar/action.yml +++ b/actions/dotnet/build-test-sonar/action.yml @@ -38,6 +38,10 @@ inputs: description: Sonar project name required: false default: "" + sonarscanner-version: + description: Version of the Sonar scanner for .NET (check latest from https://www.nuget.org/packages/dotnet-sonarscanner) + required: false + default: "12.0.0" sonar-token: description: Sonar token for login required: false @@ -55,37 +59,47 @@ runs: with: java-version: ${{ inputs.java-version }} distribution: "zulu" + # The analyzers Sonar downloads land in its user home. - name: Cache Sonar packages uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - path: ~/sonar/cache + path: ~/.sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar + # Keyed by version with no fallback, since a scanner restored from another key would be another version. - name: Cache Sonar scanner id: cache-sonar-scanner uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ./.sonar/scanner - key: ${{ runner.os }}-sonar-scanner - restore-keys: ${{ runner.os }}-sonar-scanner + key: ${{ runner.os }}-sonar-scanner-${{ inputs.sonarscanner-version }} - name: Install Sonar scanner if: ${{ steps.cache-sonar-scanner.outputs.cache-hit != 'true' }} run: | mkdir -p ./.sonar/scanner - dotnet tool update dotnet-sonarscanner --tool-path ./.sonar/scanner + dotnet tool install dotnet-sonarscanner --tool-path ./.sonar/scanner --version "$SONARSCANNER_VERSION" shell: bash + env: + SONARSCANNER_VERSION: ${{ inputs.sonarscanner-version }} - name: Start code analysis run: | - "$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" begin \ - /k:"$SONAR_PROJECT_KEY" \ - /o:"$SONAR_ORGANIZATION" \ - /n:"$SONAR_PROJECT_NAME" \ - /d:sonar.token="$SONAR_TOKEN" \ - /d:sonar.host.url="$SONAR_HOST_URL" \ - /d:sonar.cpd.exclusions="$REPORT_FOLDER/**,$SONAR_CPD_EXCLUSIONS" \ - /d:sonar.exclusions="$REPORT_FOLDER/**,$SONAR_EXCLUSIONS" \ - /d:sonar.coverageReportPaths="$REPORT_FOLDER/SonarQube.xml" \ + args=( + /k:"$SONAR_PROJECT_KEY" + /d:sonar.token="$SONAR_TOKEN" + /d:sonar.host.url="$SONAR_HOST_URL" + /d:sonar.cpd.exclusions="$REPORT_FOLDER/**,$SONAR_CPD_EXCLUSIONS" + /d:sonar.exclusions="$REPORT_FOLDER/**,$SONAR_EXCLUSIONS" + /d:sonar.coverageReportPaths="$REPORT_FOLDER/SonarQube.xml" /d:sonar.cs.vstest.reportsPaths="$REPORT_FOLDER/TestResults/*.trx" + ) + # SonarCloud needs an organization, which a SonarQube server has no notion of. + if [ -n "$SONAR_ORGANIZATION" ]; then + args+=(/o:"$SONAR_ORGANIZATION") + fi + if [ -n "$SONAR_PROJECT_NAME" ]; then + args+=(/n:"$SONAR_PROJECT_NAME") + fi + "$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" begin "${args[@]}" shell: bash working-directory: ${{ inputs.working-directory }} env: diff --git a/actions/dotnet/install-lint-restore/action.yml b/actions/dotnet/install-lint-restore/action.yml index 9820fe3..9daf962 100644 --- a/actions/dotnet/install-lint-restore/action.yml +++ b/actions/dotnet/install-lint-restore/action.yml @@ -6,6 +6,10 @@ inputs: description: .NET SDK version to be used required: false default: "10.0" + reportgenerator-version: + description: Version of ReportGenerator (check latest from https://www.nuget.org/packages/dotnet-reportgenerator-globaltool) + required: false + default: "5.5.11" working-directory: description: Directory of the .NET code, relative to the workspace, which a composite action does not take from the defaults of the calling job required: false @@ -24,9 +28,11 @@ runs: shell: bash - name: Install .NET tools run: | - dotnet tool install --global dotnet-reportgenerator-globaltool + dotnet tool install --global dotnet-reportgenerator-globaltool --version "$REPORTGENERATOR_VERSION" echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH" shell: bash + env: + REPORTGENERATOR_VERSION: ${{ inputs.reportgenerator-version }} - name: Restore .NET packages run: dotnet restore shell: bash diff --git a/actions/qemu/setup/action.yml b/actions/qemu/setup/action.yml new file mode 100644 index 0000000..f10390d --- /dev/null +++ b/actions/qemu/setup/action.yml @@ -0,0 +1,48 @@ +name: Setup QEMU +description: | + Registers QEMU user emulation for other platforms than the runner's own, so that Docker builds and runs their images, + from the qemu-user-static package of the Ubuntu archive, which apt verifies against the keys of the runner's own distribution + (replacement for docker/setup-qemu-action and its tonistiigi/binfmt image, to run no third party image with privileges). + Linux runners on Ubuntu only (for example ubuntu-latest), with sudo, since the registration is made in the kernel. + +inputs: + platforms: + description: Comma-separated platforms to check the emulation of, as Docker names them (for example "linux/arm64,linux/arm/v7") + required: false + default: "linux/arm64" + +runs: + using: "composite" + steps: + - name: Install QEMU + shell: bash + run: | + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-user-static + # The package ships its registrations in binfmt.d, which systemd-binfmt loads into the kernel. + sudo /usr/lib/systemd/systemd-binfmt + - name: Check the emulation of each platform + shell: bash + env: + PLATFORMS: ${{ inputs.platforms }} + run: | + IFS=',' read -ra requested <<< "$PLATFORMS" + for platform in "${requested[@]}"; do + platform="${platform// /}" + case "$platform" in + linux/amd64|linux/386) continue ;; + linux/arm64*) name=aarch64 ;; + linux/arm/*) name=arm ;; + linux/ppc64le) name=ppc64le ;; + linux/riscv64) name=riscv64 ;; + linux/s390x) name=s390x ;; + *) echo "ERROR: No QEMU emulation known for ${platform}" >&2; exit 1 ;; + esac + registration="/proc/sys/fs/binfmt_misc/qemu-${name}" + # The F flag opens the interpreter at registration, which is how a container without it in its file system runs the binaries. + if ! grep -qx enabled "$registration" 2>/dev/null || ! grep -q '^flags: .*F' "$registration"; then + echo "ERROR: ${platform} is not emulated: ${registration} is missing, disabled or without the F flag" >&2 + exit 1 + fi + echo "${platform} is emulated by $(grep '^interpreter' "$registration")" + done diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py index 0824ad9..2af5f48 100755 --- a/test/check_workflow_parts.py +++ b/test/check_workflow_parts.py @@ -27,7 +27,6 @@ # Parts no CI job can exercise, each with the reason, which the coverage check takes as known. UNTESTED_PARTS = { - "actions/dotnet/build-test-sonar": "needs a Sonar token", "actions/fossa/analyze": "needs a FOSSA API key", "actions/mongodb-atlas/add-runner-ip": "needs MongoDB Atlas credentials", ".github/workflows/reusable-container-publication.yml": "needs registry credentials and an OIDC token to sign", @@ -289,6 +288,13 @@ def check_references(path, document): fail(name, f"does not pass the required input '{input_name}' to '{uses}'") +def check_timeouts(path, document): + # A hung job otherwise holds a runner for the six hours GitHub allows. + for job_id, job in (document.get("jobs") or {}).items(): + if "steps" in job and "timeout-minutes" not in job: + fail(relative(path), f"job '{job_id}' runs steps without timeout-minutes") + + def check_action_versions(): versions = {} for path in [*workflow_paths(), *(action / "action.yml" for action in action_paths())]: @@ -379,9 +385,11 @@ def check_istarci(): path = ROOT / ".istarci.yml" if not path.exists(): return - runner = load(path).get("runner") or {} + config = load(path) + runner = config.get("runner") or {} jobs = list((load(ROOT / ".github/workflows/ci.yml").get("jobs") or {}).keys()) patterns = list((runner.get("images") or {}).keys()) + list(runner.get("docker") or []) + patterns += list((config.get("workflow") or {}).get("skipJobs") or []) for pattern in patterns: if not fnmatch.filter(jobs, pattern): fail(".istarci.yml", f"pattern '{pattern}' matches no job of the CI workflow") @@ -400,6 +408,7 @@ def main(): check_reusable_workflow(path) check_run_scripts(path, document) check_references(path, document) + check_timeouts(path, document) shellcheck_actions() check_action_versions() check_readme() diff --git a/test/fixtures/dotnet/Fixture.Tests.csproj b/test/fixtures/dotnet/Fixture.Tests.csproj index 3729471..c7e84aa 100644 --- a/test/fixtures/dotnet/Fixture.Tests.csproj +++ b/test/fixtures/dotnet/Fixture.Tests.csproj @@ -9,6 +9,7 @@ + From 0e0d0a1092af5908899ad7676791f32e6a63784e Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 15:06:15 +0200 Subject: [PATCH 20/55] Build arm64 natively on an arm64 runner when the caller names one, and build the images with an action of their own The publication workflow builds each platform by digest, emulated with QEMU by default or on an arm64 runner given by arm64-runner, and tags, signs and describes the image in one job. The build runs on a builder of its own, removed afterwards, which a Docker daemon shared with other jobs would otherwise keep. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/ci.yml | 85 +++++++++ .../reusable-container-publication.yml | 169 +++++++++++++++--- .istarci.yml | 5 +- README.md | 1 + actions/container/build-push/action.yml | 75 ++++++++ 5 files changed, 311 insertions(+), 24 deletions(-) create mode 100644 actions/container/build-push/action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2431180..1994eaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,6 +228,11 @@ jobs: name: "Test / QEMU setup action runs an arm64 image" runs-on: ubuntu-latest timeout-minutes: 20 + services: + registry: + image: registry:3.1.2 + ports: + - 5000:5000 steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -238,6 +243,86 @@ jobs: - name: Verify an arm64 image runs run: | [ "$(docker run --rm --platform linux/arm64 busybox:1.37 uname -m)" = aarch64 ] + - name: Build an image for two platforms, one emulated + id: emulated + uses: ./actions/container/build-push + with: + image-definition: test/fixtures/container/base/Dockerfile + image-repository: localhost:5000/emulated-test + platforms: linux/amd64,linux/arm64 + - name: Verify the manifest list holds both platforms + env: + DIGEST: ${{ steps.emulated.outputs.digest }} + run: | + platforms=$(docker buildx imagetools inspect "localhost:5000/emulated-test@${DIGEST}" --raw | jq -r '[.manifests[].platform | select(.os == "linux") | .architecture] | sort | join(",")') + [ "$platforms" = amd64,arm64 ] + + test-container-build-push: + name: "Test / Container build-push action, under a tag and by digest" + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + registry: + image: registry:3.1.2 + ports: + - 5000:5000 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Build and push the base image under a tag + id: base + uses: ./actions/container/build-push + with: + image-definition: test/fixtures/container/base/Dockerfile + image-repository: localhost:5000/build-test-base + image-tag: ${{ github.run_id }} + # The derived image fails to build unless the build argument names the base pushed above. + - name: Build and push the derived image by digest + id: derived + uses: ./actions/container/build-push + with: + image-definition: test/fixtures/container/derived/Dockerfile + image-repository: localhost:5000/build-test-derived + build-arguments: | + BASE_IMAGE=localhost:5000/build-test-base:${{ github.run_id }} + - name: Verify both images are in the registry + env: + BASE_DIGEST: ${{ steps.base.outputs.digest }} + DERIVED_DIGEST: ${{ steps.derived.outputs.digest }} + run: | + [ "$(docker buildx imagetools inspect "localhost:5000/build-test-base:${GITHUB_RUN_ID}" --format '{{json .Manifest}}' | jq -r .digest)" = "$BASE_DIGEST" ] + docker buildx imagetools inspect "localhost:5000/build-test-derived@${DERIVED_DIGEST}" >/dev/null + + test-container-build-push-arm64: + name: "Test / Container build-push action on an arm64 runner" + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + services: + registry: + image: registry:3.1.2 + ports: + - 5000:5000 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Build and push the base image natively by digest + id: native + uses: ./actions/container/build-push + with: + image-definition: test/fixtures/container/base/Dockerfile + image-repository: localhost:5000/native-test + platforms: linux/arm64 + - name: Verify the image is for arm64 + env: + DIGEST: ${{ steps.native.outputs.digest }} + run: | + image="localhost:5000/native-test@${DIGEST}" + architectures=$(docker buildx imagetools inspect "$image" --raw | jq -r '[.manifests[]?.platform | select(.os == "linux") | .architecture] | join(",")') + # A single platform pushed without attestations is a manifest rather than a list, and names its architecture in its configuration. + if [ -z "$architectures" ]; then + architectures=$(docker buildx imagetools inspect "$image" --format '{{json .Image}}' | jq -r .architecture) + fi + [ "$architectures" = arm64 ] test-mongodb-start: name: "Test / MongoDB start action" diff --git a/.github/workflows/reusable-container-publication.yml b/.github/workflows/reusable-container-publication.yml index b50dd3c..40f48aa 100644 --- a/.github/workflows/reusable-container-publication.yml +++ b/.github/workflows/reusable-container-publication.yml @@ -43,10 +43,15 @@ on: type: string required: true image-platform: - description: Image platform + description: Platforms to build, emulated with QEMU on operating-system, unless arm64-runner is set type: string required: false default: "linux/amd64,linux/arm64" + arm64-runner: + description: Runner label building linux/arm64 natively (for example "ubuntu-24.04-arm"), beside linux/amd64 on operating-system, rather than emulating every platform with QEMU on operating-system, image-platform being then not read + type: string + required: false + default: "" job-name: description: Job name type: string @@ -84,13 +89,13 @@ on: required: false jobs: - container-publication: - name: ${{ inputs.job-name }} + build-emulated: + name: ${{ inputs.job-name }} build (emulated) + if: ${{ inputs.arm64-runner == '' }} runs-on: ${{ inputs.operating-system }} timeout-minutes: ${{ inputs.timeout-minutes }} - defaults: - run: - working-directory: ${{ inputs.working-directory }} + outputs: + digest: ${{ steps.build.outputs.digest }} steps: - name: Set secret variables shell: bash @@ -114,7 +119,6 @@ jobs: # The caller being this repository, the actions under test are those of the commit under test. ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts - # The Docker CLI of the runner does what the docker/* actions wrapped, with no third party action in between. - name: Login to container registry env: REGISTRY: ${{ inputs.container-registry }} @@ -127,20 +131,139 @@ jobs: uses: ./workflow-parts/actions/qemu/setup with: platforms: ${{ inputs.image-platform }} - - name: Set up Buildx - run: docker buildx create --use --driver docker-container - - name: Build and push container image - # The gha cache backend is reachable from JavaScript actions only, so the build runs without a cache. + - name: Build and push container image by digest + id: build + uses: ./workflow-parts/actions/container/build-push + with: + image-definition: ${{ inputs.image-definition }} + image-repository: ${{ env.IMAGE_REPOSITORY }} + platforms: ${{ inputs.image-platform }} + build-arguments: ${{ inputs.extra-build-arguments }} + working-directory: ${{ inputs.working-directory }} + + build-amd64: + name: ${{ inputs.job-name }} build (linux/amd64) + if: ${{ inputs.arm64-runner != '' }} + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + digest: ${{ steps.build.outputs.digest }} + steps: + - name: Set secret variables + shell: bash + env: + SECRET_VARS: ${{ secrets.secret-vars }} + run: | + if [[ -n "$SECRET_VARS" ]]; then + echo "$SECRET_VARS" | while IFS='=' read -r _ val; do + if [[ -n "$val" ]]; then + echo "::add-mask::$val" + fi + done + echo "$SECRET_VARS" >> "$GITHUB_ENV" + fi + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout workflow parts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devpro/github-workflow-parts + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} + path: workflow-parts + - name: Login to container registry + env: + REGISTRY: ${{ inputs.container-registry }} + REGISTRY_USERNAME: ${{ secrets.container-registry-username }} + REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} + run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin + - name: Build and push container image by digest + id: build + uses: ./workflow-parts/actions/container/build-push + with: + image-definition: ${{ inputs.image-definition }} + image-repository: ${{ env.IMAGE_REPOSITORY }} + platforms: linux/amd64 + build-arguments: ${{ inputs.extra-build-arguments }} + working-directory: ${{ inputs.working-directory }} + + build-arm64: + name: ${{ inputs.job-name }} build (linux/arm64) + if: ${{ inputs.arm64-runner != '' }} + runs-on: ${{ inputs.arm64-runner }} + timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + digest: ${{ steps.build.outputs.digest }} + steps: + - name: Set secret variables + shell: bash + env: + SECRET_VARS: ${{ secrets.secret-vars }} + run: | + if [[ -n "$SECRET_VARS" ]]; then + echo "$SECRET_VARS" | while IFS='=' read -r _ val; do + if [[ -n "$val" ]]; then + echo "::add-mask::$val" + fi + done + echo "$SECRET_VARS" >> "$GITHUB_ENV" + fi + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout workflow parts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devpro/github-workflow-parts + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} + path: workflow-parts + - name: Login to container registry env: - IMAGE_DEFINITION: ${{ inputs.image-definition }} - IMAGE_PLATFORM: ${{ inputs.image-platform }} - BUILD_ARGUMENTS: ${{ inputs.extra-build-arguments }} + REGISTRY: ${{ inputs.container-registry }} + REGISTRY_USERNAME: ${{ secrets.container-registry-username }} + REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} + run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin + - name: Build and push container image by digest + id: build + uses: ./workflow-parts/actions/container/build-push + with: + image-definition: ${{ inputs.image-definition }} + image-repository: ${{ env.IMAGE_REPOSITORY }} + platforms: linux/arm64 + build-arguments: ${{ inputs.extra-build-arguments }} + working-directory: ${{ inputs.working-directory }} + + # The platforms are built apart, and only put under the tag here, so that the image a tag names is complete. + publication: + name: ${{ inputs.job-name }} + needs: [build-emulated, build-amd64, build-arm64] + # One build path is skipped by design, which an implicit success() would take for a failure. + if: ${{ !cancelled() && !contains(needs.*.result, 'failure') }} + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + steps: + - name: Checkout workflow parts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devpro/github-workflow-parts + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} + path: workflow-parts + - name: Login to container registry + env: + REGISTRY: ${{ inputs.container-registry }} + REGISTRY_USERNAME: ${{ secrets.container-registry-username }} + REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} + run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin + - name: Tag the image of every platform + env: + DIGESTS: ${{ needs.build-emulated.outputs.digest }} ${{ needs.build-amd64.outputs.digest }} ${{ needs.build-arm64.outputs.digest }} run: | - args=(--file "$IMAGE_DEFINITION" --platform "$IMAGE_PLATFORM" --tag "$IMAGE_REF" --push) - while IFS= read -r argument; do - [ -n "$argument" ] && args+=(--build-arg "$argument") - done <<< "$BUILD_ARGUMENTS" - docker buildx build "${args[@]}" . + sources=() + for digest in $DIGESTS; do + sources+=("${IMAGE_REPOSITORY}@${digest}") + done + docker buildx imagetools create --tag "$IMAGE_REF" "${sources[@]}" - name: Generate SBOM with Syft uses: ./workflow-parts/actions/syft/generate-sbom continue-on-error: true @@ -156,6 +279,8 @@ jobs: image-name: ${{ inputs.image-name }} image-path: ${{ inputs.image-path }} image-tag: ${{ inputs.image-tag }} - env: - IMAGE_REF: ${{ inputs.image-path }}/${{ inputs.image-name }}:${{ inputs.image-tag }} - IMAGE_REF_LATEST: ${{ inputs.image-path }}/${{ inputs.image-name }}:latest + +env: + IMAGE_REPOSITORY: ${{ inputs.image-path }}/${{ inputs.image-name }} + IMAGE_REF: ${{ inputs.image-path }}/${{ inputs.image-name }}:${{ inputs.image-tag }} + IMAGE_REF_LATEST: ${{ inputs.image-path }}/${{ inputs.image-name }}:latest diff --git a/.istarci.yml b/.istarci.yml index e05fcb8..690b38e 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -5,10 +5,11 @@ runner: images: "test-dotnet-*": ghcr.io/devpro/ubuntu-dotnet:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. - docker: ["test-container-scan-on-base*", "test-cosign-sign*"] + docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push"] # The Sonar path of the .NET quality workflow runs against a SonarQube of this machine, which GitHub does not have. sonarqube: true workflow: # Registering an emulator changes the kernel of the machine, which a local run leaves alone. - skipJobs: [test-qemu-setup] + # An arm64 runner is a GitHub hosted machine no local run has yet. + skipJobs: [test-qemu-setup, test-container-build-push-arm64] diff --git a/README.md b/README.md index 16a5b0c..4212f56 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Terraform: Containers: +- [Build and push an image, under a tag or by digest](actions/container/build-push/action.yml) - [Sign an image with Cosign](actions/cosign/sign/action.yml) - [Setup QEMU to build images for other platforms](actions/qemu/setup/action.yml) - [Scan an image or a directory with Trivy](actions/trivy/scan/action.yml) diff --git a/actions/container/build-push/action.yml b/actions/container/build-push/action.yml new file mode 100644 index 0000000..e7b0336 --- /dev/null +++ b/actions/container/build-push/action.yml @@ -0,0 +1,75 @@ +name: Build and push a container image +description: | + Builds a container image with Docker Buildx for one or more platforms and pushes it, + under a tag, or by digest only for a job that merges the digests of several platforms afterwards. + The registry login is left to the job, which holds the credentials. + Linux runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + image-definition: + description: Path to the container definition file (Dockerfile, Containerfile), relative to the working directory + required: true + image-repository: + description: Repository of the image, with its registry (for example docker.io/devprofr/app) + required: true + image-tag: + description: Tag to push, or empty to push by digest only + required: false + default: "" + platforms: + description: Comma-separated platforms to build (a platform other than the runner's own needs the Setup QEMU action first) + required: false + default: "linux/amd64" + build-arguments: + description: Build arguments, one KEY=value per line + required: false + default: "" + working-directory: + description: Directory the build runs from, relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." + +outputs: + digest: + description: Digest of the pushed image, a manifest list when several platforms were built + value: ${{ steps.build.outputs.digest }} + +runs: + using: "composite" + steps: + # The Docker CLI of the runner does what the docker/* actions wrapped, with no third party action in between. + # The gha cache backend is reachable from JavaScript actions only, so the build runs without a cache. + - name: Build and push container image + id: build + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + IMAGE_DEFINITION: ${{ inputs.image-definition }} + IMAGE_REPOSITORY: ${{ inputs.image-repository }} + IMAGE_TAG: ${{ inputs.image-tag }} + PLATFORMS: ${{ inputs.platforms }} + BUILD_ARGUMENTS: ${{ inputs.build-arguments }} + run: | + metadata="$(mktemp)" + # A builder of its own, removed afterwards, since a Docker daemon shared with other jobs keeps what a job leaves on it. + # It shares the network of the runner, which is how it reaches a registry listening on localhost. + builder="build-push-${GITHUB_RUN_ID:-local}-$$" + docker buildx create --name "$builder" --driver docker-container --driver-opt network=host >/dev/null + trap 'docker buildx rm "$builder" >/dev/null 2>&1 || true' EXIT + args=(--builder "$builder" --file "$IMAGE_DEFINITION" --platform "$PLATFORMS" --metadata-file "$metadata") + if [ -n "$IMAGE_TAG" ]; then + args+=(--tag "${IMAGE_REPOSITORY}:${IMAGE_TAG}" --push) + else + args+=(--output "type=image,name=${IMAGE_REPOSITORY},push-by-digest=true,name-canonical=true,push=true") + fi + while IFS= read -r argument; do + [ -n "$argument" ] && args+=(--build-arg "$argument") + done <<< "$BUILD_ARGUMENTS" + docker buildx build "${args[@]}" . + digest="$(jq -r '."containerimage.digest" // empty' "$metadata")" + if [[ "$digest" != sha256:* ]]; then + echo "ERROR: The build reported no digest for ${IMAGE_REPOSITORY}" >&2 + exit 1 + fi + echo "Pushed ${IMAGE_REPOSITORY}@${digest}" + echo "digest=$digest" >> "$GITHUB_OUTPUT" From 85f8c0b5d35d2bbf97975cd915068f363414abd4 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 15:08:04 +0200 Subject: [PATCH 21/55] Read the run id of the build-push test from the expression its tag was made with Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1994eaa..fde409e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -287,10 +287,11 @@ jobs: BASE_IMAGE=localhost:5000/build-test-base:${{ github.run_id }} - name: Verify both images are in the registry env: + BASE_TAG: ${{ github.run_id }} BASE_DIGEST: ${{ steps.base.outputs.digest }} DERIVED_DIGEST: ${{ steps.derived.outputs.digest }} run: | - [ "$(docker buildx imagetools inspect "localhost:5000/build-test-base:${GITHUB_RUN_ID}" --format '{{json .Manifest}}' | jq -r .digest)" = "$BASE_DIGEST" ] + [ "$(docker buildx imagetools inspect "localhost:5000/build-test-base:${BASE_TAG}" --format '{{json .Manifest}}' | jq -r .digest)" = "$BASE_DIGEST" ] docker buildx imagetools inspect "localhost:5000/build-test-derived@${DERIVED_DIGEST}" >/dev/null test-container-build-push-arm64: From 92a1f1e5edc6edd9fe42eb44bc62f89c7d45348f Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 16:42:45 +0200 Subject: [PATCH 22/55] Run the arm64 build test locally, and read the run id from the runner's variable again Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/ci.yml | 3 +-- .istarci.yml | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fde409e..1994eaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -287,11 +287,10 @@ jobs: BASE_IMAGE=localhost:5000/build-test-base:${{ github.run_id }} - name: Verify both images are in the registry env: - BASE_TAG: ${{ github.run_id }} BASE_DIGEST: ${{ steps.base.outputs.digest }} DERIVED_DIGEST: ${{ steps.derived.outputs.digest }} run: | - [ "$(docker buildx imagetools inspect "localhost:5000/build-test-base:${BASE_TAG}" --format '{{json .Manifest}}' | jq -r .digest)" = "$BASE_DIGEST" ] + [ "$(docker buildx imagetools inspect "localhost:5000/build-test-base:${GITHUB_RUN_ID}" --format '{{json .Manifest}}' | jq -r .digest)" = "$BASE_DIGEST" ] docker buildx imagetools inspect "localhost:5000/build-test-derived@${DERIVED_DIGEST}" >/dev/null test-container-build-push-arm64: diff --git a/.istarci.yml b/.istarci.yml index 690b38e..1f79de2 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -5,11 +5,10 @@ runner: images: "test-dotnet-*": ghcr.io/devpro/ubuntu-dotnet:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. - docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push"] + docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] # The Sonar path of the .NET quality workflow runs against a SonarQube of this machine, which GitHub does not have. sonarqube: true workflow: # Registering an emulator changes the kernel of the machine, which a local run leaves alone. - # An arm64 runner is a GitHub hosted machine no local run has yet. - skipJobs: [test-qemu-setup, test-container-build-push-arm64] + skipJobs: [test-qemu-setup] From 6bc9d83a2892efe21618184219734ad34ad50dd6 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 16:47:42 +0200 Subject: [PATCH 23/55] Name the jobs a local run leaves to GitHub, and why Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- CONTRIBUTING.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8a9e16..9155d42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,12 @@ [IstarCI](https://github.com/devpro/istarci) runs the GitHub Actions pipeline in Docker before the push. `istarci add` and `istarci install-hook` set it up for this repository. +### Jobs left to GitHub + +`test-qemu-setup` registers an emulator in the kernel, so `.istarci.yml` skips it, as a local run leaves the machine unchanged. +`test-container-build-push-arm64` builds arm64 images with `RUN` steps, which only an arm64 processor executes natively. +IstarCI runs it on an arm64 machine, emulated on an amd64 one with a `qemu-aarch64` registration holding the F flag (from `qemu-user-static`), and skips it with a warning otherwise. + ## Run the static checks `test/run_static_checks.sh` runs actionlint, shellcheck on the scripts of the composite actions, and the conventions of `test/check_workflow_parts.py`. From 012d11a70de1ce33c326d5ba33326be88e6e7af0 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 19:29:55 +0200 Subject: [PATCH 24/55] Download the arm64 build of each tool on an arm64 runner Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/ci.yml | 37 ++++++++++++++++++++++++++ CONTRIBUTING.md | 4 +-- actions/chart-testing/setup/action.yml | 5 ++-- actions/cosign/sign/action.yml | 2 +- actions/fossa/analyze/action.yml | 4 +-- actions/goreleaser/setup/action.yml | 4 +-- actions/helm/setup/action.yml | 11 ++++---- actions/mongodb/start/action.yml | 14 ++++++---- actions/syft/generate-sbom/action.yml | 4 +-- actions/terraform/setup/action.yml | 4 +-- actions/tflint/setup/action.yml | 4 +-- actions/trivy/scan/action.yml | 4 +-- 12 files changed, 70 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1994eaa..c78ff6f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -324,6 +324,43 @@ jobs: fi [ "$architectures" = arm64 ] + # Each tool runs once installed, which a binary built for another processor does not. + test-setup-arm64: + name: "Test / Setup actions install arm64 binaries on an arm64 runner" + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Terraform + uses: ./actions/terraform/setup + - name: Setup TFLint + uses: ./actions/tflint/setup + - name: Setup Helm + uses: ./actions/helm/setup + - name: Setup chart-testing + uses: ./actions/chart-testing/setup + - name: Setup GoReleaser + uses: ./actions/goreleaser/setup + - name: Start MongoDB + uses: ./actions/mongodb/start + - name: Generate SBOM with Syft + uses: ./actions/syft/generate-sbom + with: + target-path: test/fixtures/container + upload-artifact: "false" + - name: Scan with Trivy + uses: ./actions/trivy/scan + with: + scan-type: config + target: test/fixtures/container + - name: Verify the binaries are for arm64 + run: | + for tool in terraform tflint helm ct goreleaser mongod mongosh syft trivy; do + # The ELF machine field, 0xb7 being aarch64, read without the file command a runner image may lack. + [ "$(od -An -tx1 -j18 -N1 "$(command -v "$tool")" | tr -d ' ')" = b7 ] + done + test-mongodb-start: name: "Test / MongoDB start action" runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9155d42..e45afeb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,8 @@ ### Jobs left to GitHub `test-qemu-setup` registers an emulator in the kernel, so `.istarci.yml` skips it, as a local run leaves the machine unchanged. -`test-container-build-push-arm64` builds arm64 images with `RUN` steps, which only an arm64 processor executes natively. -IstarCI runs it on an arm64 machine, emulated on an amd64 one with a `qemu-aarch64` registration holding the F flag (from `qemu-user-static`), and skips it with a warning otherwise. +The jobs on `ubuntu-24.04-arm` run arm64 binaries, the tools installed and the `RUN` steps of the images built, which only an arm64 processor executes natively. +IstarCI runs them on an arm64 machine, emulated on an amd64 one with a `qemu-aarch64` registration holding the F flag (from `qemu-user-static`), and skips them with a warning otherwise. ## Run the static checks diff --git a/actions/chart-testing/setup/action.yml b/actions/chart-testing/setup/action.yml index 1639191..c680e7f 100644 --- a/actions/chart-testing/setup/action.yml +++ b/actions/chart-testing/setup/action.yml @@ -3,7 +3,7 @@ description: | Installs the chart-testing CLI (ct) by downloading the official release and verifying its SHA256 checksum, with the yamllint and yamale it calls (replacement for helm/chart-testing-action, to reduce third-party GitHub Action supply-chain risk). Helm and git must be installed beforehand, for example with the Setup Helm action. - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: chart-testing-version: @@ -26,12 +26,13 @@ runs: shell: bash env: CT_VERSION: ${{ inputs.chart-testing-version }} + CT_ARCH: ${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }} YAMLLINT_VERSION: ${{ inputs.yamllint-version }} YAMALE_VERSION: ${{ inputs.yamale-version }} run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. cd "$(mktemp -d)" - CT_TAR="chart-testing_${CT_VERSION#v}_linux_amd64.tar.gz" + CT_TAR="chart-testing_${CT_VERSION#v}_linux_${CT_ARCH}.tar.gz" BASE_URL="https://github.com/helm/chart-testing/releases/download/${CT_VERSION}" echo "Downloading chart-testing binary and checksums..." diff --git a/actions/cosign/sign/action.yml b/actions/cosign/sign/action.yml index 7bcecd0..a8f5f2b 100644 --- a/actions/cosign/sign/action.yml +++ b/actions/cosign/sign/action.yml @@ -59,7 +59,7 @@ runs: fi shell: bash env: - COSIGN_BINARY: cosign-linux-amd64 + COSIGN_BINARY: cosign-linux-${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }} COSIGN_CHECKSUM_FILE: cosign_checksums.txt COSIGN_VERSION: ${{ inputs.cosign-version }} - name: Get image digest diff --git a/actions/fossa/analyze/action.yml b/actions/fossa/analyze/action.yml index d0124a4..a8c8740 100644 --- a/actions/fossa/analyze/action.yml +++ b/actions/fossa/analyze/action.yml @@ -3,7 +3,7 @@ description: | Installs the FOSSA CLI by downloading the official binary from its GitHub release and verifying its SHA256 checksum, then analyzes the dependencies for license compliance, optionally tests them against the policy, and writes an HTML report (replacement for fossas/fossa-action, to reduce third-party GitHub Action supply-chain risk). - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: fossa-version: @@ -33,7 +33,7 @@ runs: shell: bash env: FOSSA_VERSION: ${{ inputs.fossa-version }} - FOSSA_TARBALL: fossa_${{ inputs.fossa-version }}_linux_amd64.tar.gz + FOSSA_TARBALL: fossa_${{ inputs.fossa-version }}_linux_${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }}.tar.gz run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. cd "$(mktemp -d)" diff --git a/actions/goreleaser/setup/action.yml b/actions/goreleaser/setup/action.yml index 6828f50..88b9d7f 100644 --- a/actions/goreleaser/setup/action.yml +++ b/actions/goreleaser/setup/action.yml @@ -2,7 +2,7 @@ name: Setup GoReleaser description: | Installs GoReleaser by downloading the official binary from its GitHub release and verifying its SHA256 checksum (replacement for goreleaser/goreleaser-action, to reduce third-party GitHub Action supply-chain risk). - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: goreleaser-version: @@ -17,7 +17,7 @@ runs: shell: bash env: GORELEASER_VERSION: ${{ inputs.goreleaser-version }} - GORELEASER_TARBALL: goreleaser_Linux_x86_64.tar.gz + GORELEASER_TARBALL: goreleaser_Linux_${{ runner.arch == 'ARM64' && 'arm64' || 'x86_64' }}.tar.gz run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. cd "$(mktemp -d)" diff --git a/actions/helm/setup/action.yml b/actions/helm/setup/action.yml index cc218e4..f128769 100644 --- a/actions/helm/setup/action.yml +++ b/actions/helm/setup/action.yml @@ -2,7 +2,7 @@ name: Setup Helm description: | Installs the Helm CLI by downloading the official binary from get.helm.sh and verifying its SHA256 checksum (replacement for azure/setup-helm, to reduce third-party GitHub Action supply-chain risk). - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: helm-version: @@ -17,10 +17,11 @@ runs: shell: bash env: HELM_VERSION: ${{ inputs.helm-version }} + HELM_PLATFORM: linux-${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }} run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. cd "$(mktemp -d)" - HELM_TAR="helm-${HELM_VERSION}-linux-amd64.tar.gz" + HELM_TAR="helm-${HELM_VERSION}-${HELM_PLATFORM}.tar.gz" BASE_URL="https://get.helm.sh" echo "Downloading Helm binary and checksum..." @@ -30,13 +31,13 @@ runs: echo "Verifying checksum..." if sha256sum --check --status "${HELM_TAR}.sha256sum"; then echo "Verification successful!" - tar -xzf "${HELM_TAR}" linux-amd64/helm + tar -xzf "${HELM_TAR}" "${HELM_PLATFORM}/helm" # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. mkdir -p "${RUNNER_TEMP}/bin" - mv linux-amd64/helm "${RUNNER_TEMP}/bin/helm" + mv "${HELM_PLATFORM}/helm" "${RUNNER_TEMP}/bin/helm" echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" export PATH="${RUNNER_TEMP}/bin:$PATH" - rm -rf linux-amd64 "${HELM_TAR}" "${HELM_TAR}.sha256sum" + rm -rf "${HELM_PLATFORM}" "${HELM_TAR}" "${HELM_TAR}.sha256sum" helm version else echo "ERROR: Checksum verification failed!" >&2 diff --git a/actions/mongodb/start/action.yml b/actions/mongodb/start/action.yml index 0153ea5..b037eaa 100644 --- a/actions/mongodb/start/action.yml +++ b/actions/mongodb/start/action.yml @@ -3,7 +3,7 @@ description: | Starts a local MongoDB server on 127.0.0.1:27017, with mongosh on the path, from the official archives with their SHA256 checksums verified rather than the apt packages, so neither root nor sudo is needed, on a hosted runner or in a container. - Linux x64 runners on Ubuntu 24.04 only (for example ubuntu-latest). + Linux x64 or arm64 runners on Ubuntu 24.04 only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: mongodb-version: @@ -15,9 +15,13 @@ inputs: required: false default: "2.13.0" mongosh-sha256: - description: SHA256 of the mongosh archive, given with any other mongosh-version, since its release publishes no checksum file + description: SHA256 of the mongosh archive for x64, given with any other mongosh-version, since its release publishes no checksum file required: false default: "b2089e67641a28aa621476c4d62c69f66b9a41484baba24d8a8b1f5f96e92d0b" + mongosh-sha256-arm64: + description: SHA256 of the mongosh archive for arm64, given with any other mongosh-version + required: false + default: "a124ec6680c70ceabc61696bbd110f0da05d7c52524ed402ae9511b5aec7fe32" runs: using: "composite" @@ -25,10 +29,10 @@ runs: - name: Install & start MongoDB shell: bash env: - MONGODB_ARCHIVE: mongodb-linux-x86_64-ubuntu2404-${{ inputs.mongodb-version }}.tgz + MONGODB_ARCHIVE: mongodb-linux-${{ runner.arch == 'ARM64' && 'aarch64' || 'x86_64' }}-ubuntu2404-${{ inputs.mongodb-version }}.tgz MONGOSH_VERSION: ${{ inputs.mongosh-version }} - MONGOSH_ARCHIVE: mongosh-${{ inputs.mongosh-version }}-linux-x64.tgz - MONGOSH_SHA256: ${{ inputs.mongosh-sha256 }} + MONGOSH_ARCHIVE: mongosh-${{ inputs.mongosh-version }}-linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}.tgz + MONGOSH_SHA256: ${{ runner.arch == 'ARM64' && inputs.mongosh-sha256-arm64 || inputs.mongosh-sha256 }} run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like an archive. cd "$(mktemp -d)" diff --git a/actions/syft/generate-sbom/action.yml b/actions/syft/generate-sbom/action.yml index 8475850..402be86 100644 --- a/actions/syft/generate-sbom/action.yml +++ b/actions/syft/generate-sbom/action.yml @@ -3,7 +3,7 @@ description: | Installs Syft by downloading the official binary from its GitHub release, verifying its SHA256 checksum, and uses it to generate a Software Bill of Materials for a container image or a source directory (replacement for anchore/sbom-action, to reduce third-party GitHub Action supply-chain risk). - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: syft-version: @@ -42,7 +42,7 @@ runs: shell: bash env: SYFT_VERSION: ${{ inputs.syft-version }} - SYFT_TARBALL: syft_${{ inputs.syft-version }}_linux_amd64.tar.gz + SYFT_TARBALL: syft_${{ inputs.syft-version }}_linux_${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }}.tar.gz SYFT_CHECKSUMS: syft_${{ inputs.syft-version }}_checksums.txt run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. diff --git a/actions/terraform/setup/action.yml b/actions/terraform/setup/action.yml index 1a8894a..da3d4a6 100644 --- a/actions/terraform/setup/action.yml +++ b/actions/terraform/setup/action.yml @@ -2,7 +2,7 @@ name: Setup Terraform description: | Installs the Terraform CLI by downloading the official binary from HashiCorp's release server and verifying its SHA256 checksum (replacement for hashicorp/setup-terraform, to reduce third-party GitHub Action supply-chain risk). - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: terraform-version: @@ -17,7 +17,7 @@ runs: shell: bash env: TERRAFORM_VERSION: ${{ inputs.terraform-version }} - TERRAFORM_ZIP: terraform_${{ inputs.terraform-version }}_linux_amd64.zip + TERRAFORM_ZIP: terraform_${{ inputs.terraform-version }}_linux_${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }}.zip TERRAFORM_SHA256SUMS: terraform_${{ inputs.terraform-version }}_SHA256SUMS run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. diff --git a/actions/tflint/setup/action.yml b/actions/tflint/setup/action.yml index f4f5cb5..c6187e0 100644 --- a/actions/tflint/setup/action.yml +++ b/actions/tflint/setup/action.yml @@ -2,7 +2,7 @@ name: Setup TFLint description: | Installs TFLint by downloading the official binary from its GitHub release and verifying its SHA256 checksum (replacement for terraform-linters/setup-tflint, to reduce third-party GitHub Action supply-chain risk). - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: tflint-version: @@ -17,7 +17,7 @@ runs: shell: bash env: TFLINT_VERSION: ${{ inputs.tflint-version }} - TFLINT_ZIP: tflint_linux_amd64.zip + TFLINT_ZIP: tflint_linux_${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }}.zip run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. cd "$(mktemp -d)" diff --git a/actions/trivy/scan/action.yml b/actions/trivy/scan/action.yml index 4a3124d..805e61c 100644 --- a/actions/trivy/scan/action.yml +++ b/actions/trivy/scan/action.yml @@ -3,7 +3,7 @@ description: | Installs Trivy by downloading the official binary from its GitHub release and verifying its SHA256 checksum, then scans a container image or a source directory for vulnerabilities or misconfigurations (replacement for aquasecurity/trivy-action, to reduce third-party GitHub Action supply-chain risk). - Linux runners only (for example ubuntu-latest). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). inputs: trivy-version: @@ -49,7 +49,7 @@ runs: shell: bash env: TRIVY_VERSION: ${{ inputs.trivy-version }} - TRIVY_TARBALL: trivy_${{ inputs.trivy-version }}_Linux-64bit.tar.gz + TRIVY_TARBALL: trivy_${{ inputs.trivy-version }}_Linux-${{ runner.arch == 'ARM64' && 'ARM64' || '64bit' }}.tar.gz TRIVY_CHECKSUMS: trivy_${{ inputs.trivy-version }}_checksums.txt run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. From aad82edc741c177fad5dfbb27863df817c92c244 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 19 Oct 2026 19:37:10 +0200 Subject: [PATCH 25/55] Analyze and inventory the project in the working directory rather than the whole repository Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6LLRv79eCs5Zj1jSPK3MP --- .github/workflows/reusable-dotnet-quality.yml | 6 ++++-- actions/fossa/analyze/action.yml | 9 ++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index b7b79b5..1ef3e11 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -202,20 +202,22 @@ jobs: run-tests: ${{ inputs.fossa-test && github.event_name == 'pull_request' }} test-diff-revision: ${{ github.event.pull_request.base.sha }} report-file: report/fossa.html + working-directory: ${{ inputs.working-directory }} - name: Generate SBOM with Syft uses: ./workflow-parts/actions/syft/generate-sbom continue-on-error: true + with: + target-path: ${{ inputs.working-directory }} - name: Archive test results if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dotnet-test-results - # The build and test actions write the results, the coverage and the reports in the report folder of the working directory. + # The build, test and FOSSA actions write the results, the coverage and the reports in the report folder of the working directory. path: | ${{ inputs.working-directory }}/report/ ./**/*test-result.xml ./**/failure_*.png* - ./**/fossa.html env: DOTNET_CLI_TELEMETRY_OPTOUT: 1 DOTNET_NOLOGO: 1 diff --git a/actions/fossa/analyze/action.yml b/actions/fossa/analyze/action.yml index a8c8740..856e01c 100644 --- a/actions/fossa/analyze/action.yml +++ b/actions/fossa/analyze/action.yml @@ -22,9 +22,13 @@ inputs: required: false default: "" report-file: - description: Path of the generated HTML report (empty writes none) + description: Path of the generated HTML report, relative to the working directory (empty writes none) required: false default: "report/fossa.html" + working-directory: + description: Directory of the project to analyze, relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." runs: using: "composite" @@ -63,6 +67,7 @@ runs: - name: Analyze dependencies shell: bash + working-directory: ${{ inputs.working-directory }} env: FOSSA_API_KEY: ${{ inputs.api-key }} run: fossa analyze @@ -70,6 +75,7 @@ runs: - name: Test dependencies against the policy if: ${{ inputs.run-tests == 'true' }} shell: bash + working-directory: ${{ inputs.working-directory }} env: FOSSA_API_KEY: ${{ inputs.api-key }} DIFF_REVISION: ${{ inputs.test-diff-revision }} @@ -83,6 +89,7 @@ runs: - name: Write HTML report if: ${{ inputs.report-file != '' }} shell: bash + working-directory: ${{ inputs.working-directory }} env: FOSSA_API_KEY: ${{ inputs.api-key }} REPORT_FILE: ${{ inputs.report-file }} From 39c3a1b3128920eac378f577bb30a4dc3dd283d2 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 00:01:48 +0200 Subject: [PATCH 26/55] Add the git check and Pages workflows, the npm publish, Playwright, Task and change check actions, and let the Terraform deployment destroy Each one replaces steps several repositories copied, and is tested in the CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 158 +++++++++++++++++- .github/workflows/reusable-git-check.yml | 94 +++++++++++ .../workflows/reusable-pages-deployment.yml | 123 ++++++++++++++ .../reusable-terraform-deployment.yml | 8 + .istarci.yml | 3 + README.md | 23 ++- actions/git/check-changes/action.yml | 49 ++++++ actions/npm/publish/action.yml | 53 ++++++ actions/playwright/install/action.yml | 36 ++++ actions/task/setup/action.yml | 44 +++++ test/check_workflow_parts.py | 2 + test/fixtures/npm/published/package.json | 4 + test/fixtures/npm/unpublished/index.js | 1 + test/fixtures/npm/unpublished/package.json | 5 + test/fixtures/pages/.gitignore | 1 + test/fixtures/pages/build.js | 4 + test/fixtures/pages/package-lock.json | 10 ++ test/fixtures/pages/package.json | 7 + test/fixtures/playwright/package-lock.json | 40 +++++ test/fixtures/playwright/package.json | 7 + test/fixtures/version/Directory.Build.props | 5 + 21 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/reusable-git-check.yml create mode 100644 .github/workflows/reusable-pages-deployment.yml create mode 100644 actions/git/check-changes/action.yml create mode 100644 actions/npm/publish/action.yml create mode 100644 actions/playwright/install/action.yml create mode 100644 actions/task/setup/action.yml create mode 100644 test/fixtures/npm/published/package.json create mode 100644 test/fixtures/npm/unpublished/index.js create mode 100644 test/fixtures/npm/unpublished/package.json create mode 100644 test/fixtures/pages/.gitignore create mode 100644 test/fixtures/pages/build.js create mode 100644 test/fixtures/pages/package-lock.json create mode 100644 test/fixtures/pages/package.json create mode 100644 test/fixtures/playwright/package-lock.json create mode 100644 test/fixtures/playwright/package.json create mode 100644 test/fixtures/version/Directory.Build.props diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c78ff6f..c286a5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,12 +76,17 @@ jobs: uses: ./actions/helm/setup with: helm-version: v3.16.4 + - name: Setup Task + uses: ./actions/task/setup + with: + task-version: v3.52.0 - name: Verify the versions run: | # Compared once captured, since a reader closing the pipe early fails the step with SIGPIPE under pipefail. [[ "$(terraform version)" == "Terraform v1.9.8"* ]] [[ "$(tflint --version)" == *"version 0.53.0"* ]] [[ "$(helm version --short)" == v3.16.4+* ]] + [[ "$(task --version)" == *3.52.0* ]] # A setup action that cannot verify what it downloaded stops the job rather than running it. test-setup-fails-closed: @@ -115,6 +120,12 @@ jobs: image-path: docker.io/library image-tag: "1.37" readonly: "true" + - name: Install a Task release that does not exist + id: task-missing-release + continue-on-error: true + uses: ./actions/task/setup + with: + task-version: v0.0.0 - name: Start MongoDB with a mongosh archive whose checksum does not match id: mongodb-wrong-checksum continue-on-error: true @@ -175,6 +186,20 @@ jobs: goreleaser --version command -v goreleaser + test-task-setup: + name: "Test / Task setup action" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Task + uses: ./actions/task/setup + - name: Verify task is installed + run: | + task --version + command -v task + test-cosign-sign: name: "Test / Cosign sign action, without signing" runs-on: ubuntu-latest @@ -342,6 +367,8 @@ jobs: uses: ./actions/chart-testing/setup - name: Setup GoReleaser uses: ./actions/goreleaser/setup + - name: Setup Task + uses: ./actions/task/setup - name: Start MongoDB uses: ./actions/mongodb/start - name: Generate SBOM with Syft @@ -356,7 +383,7 @@ jobs: target: test/fixtures/container - name: Verify the binaries are for arm64 run: | - for tool in terraform tflint helm ct goreleaser mongod mongosh syft trivy; do + for tool in terraform tflint helm ct goreleaser task mongod mongosh syft trivy; do # The ELF machine field, 0xb7 being aarch64, read without the file command a runner image may lack. [ "$(od -An -tx1 -j18 -N1 "$(command -v "$tool")" | tr -d ' ')" = b7 ] done @@ -504,3 +531,132 @@ jobs: image-name: scan-test image-path: local image-tag: ${{ github.run_id }} + + test-git-check-changes: + name: "Test / Git check-changes action" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The empty tree of git stands for a base holding nothing, which needs no history in the clone. + - name: Compare a path with the empty tree + id: added + uses: ./actions/git/check-changes + with: + paths: README.md actions + base: 4b825dc642cb6eb9a060e54bf8d69288fbee4904 + - name: Compare a path that does not exist with the empty tree + id: absent + uses: ./actions/git/check-changes + with: + paths: path-that-does-not-exist + base: 4b825dc642cb6eb9a060e54bf8d69288fbee4904 + - name: Compare a path with the commit itself + id: unchanged + uses: ./actions/git/check-changes + with: + paths: README.md + base: HEAD + - name: Compare a path with a commit the clone does not have + id: unreadable + uses: ./actions/git/check-changes + with: + paths: README.md + base: "0000000000000000000000000000000000000000" + - name: Verify the comparisons + env: + ADDED: ${{ steps.added.outputs.changed }} + ABSENT: ${{ steps.absent.outputs.changed }} + UNCHANGED: ${{ steps.unchanged.outputs.changed }} + UNREADABLE: ${{ steps.unreadable.outputs.changed }} + run: | + [ "$ADDED" = true ] + [ "$ABSENT" = false ] + [ "$UNCHANGED" = false ] + [ "$UNREADABLE" = true ] + + test-git-check: + name: "Test / Git check reusable workflow" + uses: ./.github/workflows/reusable-git-check.yml + with: + paths: test/fixtures + version-file: test/fixtures/version/Directory.Build.props + + test-git-check-outputs: + name: "Test / Git check reusable workflow outputs" + needs: test-git-check + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify the outputs + env: + BRANCH_SLUG: ${{ needs.test-git-check.outputs.branch-slug }} + CHANGED: ${{ needs.test-git-check.outputs.changed }} + VERSION_MAJOR_MINOR: ${{ needs.test-git-check.outputs.version-major-minor }} + run: | + [ "$VERSION_MAJOR_MINOR" = 1.2 ] + [[ "$BRANCH_SLUG" =~ ^[a-z0-9-]+$ ]] + [[ "$CHANGED" =~ ^(true|false)$ ]] + + test-npm-publish: + name: "Test / npm publish action, without publishing" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Publish a version the registry already has + id: published + uses: ./actions/npm/publish + with: + publish-command: "false" + working-directory: test/fixtures/npm/published + # A dry run goes through every step of a publication but the upload, which needs no credentials. + - name: Publish a package the registry does not have + id: unpublished + uses: ./actions/npm/publish + with: + publish-command: npm publish --dry-run + working-directory: test/fixtures/npm/unpublished + - name: Verify only the new package was published + env: + PUBLISHED: ${{ steps.published.outputs.published }} + PUBLISHED_VERSION: ${{ steps.published.outputs.version }} + UNPUBLISHED: ${{ steps.unpublished.outputs.published }} + run: | + [ "$PUBLISHED" = false ] + [ "$PUBLISHED_VERSION" = 1.3.0 ] + [ "$UNPUBLISHED" = true ] + + test-playwright-install: + name: "Test / Playwright install action" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the dependencies + working-directory: test/fixtures/playwright + run: npm ci + - name: Install Playwright browsers + uses: ./actions/playwright/install + with: + working-directory: test/fixtures/playwright + - name: Verify the browser starts + working-directory: test/fixtures/playwright + run: node -e "require('playwright').chromium.launch().then((browser) => browser.close())" + + test-pages-deployment: + name: "Test / Pages deployment reusable workflow, without deploying" + uses: ./.github/workflows/reusable-pages-deployment.yml + with: + build-command: | + task --version + npm ci + npm run build + deploy: false + node-version: "24" + site-path: _site + task-enabled: true + working-directory: test/fixtures/pages diff --git a/.github/workflows/reusable-git-check.yml b/.github/workflows/reusable-git-check.yml new file mode 100644 index 0000000..4f69d10 --- /dev/null +++ b/.github/workflows/reusable-git-check.yml @@ -0,0 +1,94 @@ +name: Reusable - Git check + +on: + workflow_call: + inputs: + job-name: + description: Job name + type: string + required: false + default: Check + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 10 + operating-system: + description: Operating system executing the runner + type: string + required: false + default: ubuntu-latest + paths: + description: Paths whose change the changed output tells, separated by spaces + type: string + required: false + default: "src test" + version-file: + description: MSBuild file whose VersionPrefix gives the version-major-minor output (Directory.Build.props for instance), none when empty + type: string + required: false + default: "" + workflow-parts-version: + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." + type: string + required: false + default: "main" + outputs: + branch-slug: + description: Branch name without its first segment (feature/, release/), lower case, with only letters, digits and dashes, to name an environment + value: ${{ jobs.git-check.outputs.branch-slug }} + changed: + description: "\"true\" when the commit changes one of the paths, or when there is no base to compare with" + value: ${{ jobs.git-check.outputs.changed }} + version-major-minor: + description: Major and minor parts of the VersionPrefix of the version file, to tag an image + value: ${{ jobs.git-check.outputs.version-major-minor }} + +jobs: + git-check: + name: ${{ inputs.job-name }} + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + branch-slug: ${{ steps.slug.outputs.branch-slug }} + changed: ${{ steps.changes.outputs.changed }} + version-major-minor: ${{ steps.version.outputs.version-major-minor }} + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The base of the comparison is in the history. + fetch-depth: 0 + - name: Checkout workflow parts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devpro/github-workflow-parts + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} + path: workflow-parts + - name: Check changes + id: changes + uses: ./workflow-parts/actions/git/check-changes + with: + paths: ${{ inputs.paths }} + - name: Read the version + id: version + if: inputs.version-file != '' + env: + VERSION_FILE: ${{ inputs.version-file }} + run: | + version=$(grep -oP '(?<=)[^<]+' "$VERSION_FILE" | head -n 1 | tr -d '[:space:]') + if [ -z "$version" ]; then + echo "ERROR: no VersionPrefix in ${VERSION_FILE}" >&2 + exit 1 + fi + echo "Version ${version}" + echo "version-major-minor=$(cut -d . -f 1-2 <<< "$version")" >> "$GITHUB_OUTPUT" + # A pull request names its branch in GITHUB_HEAD_REF, GITHUB_REF_NAME holding its merge ref. + - name: Name the branch + id: slug + run: | + branch="${GITHUB_HEAD_REF:-$GITHUB_REF_NAME}" + slug=$(tr '/' '-' <<< "${branch#*/}" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-') + echo "Branch ${branch}, slug ${slug}" + echo "branch-slug=${slug}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/reusable-pages-deployment.yml b/.github/workflows/reusable-pages-deployment.yml new file mode 100644 index 0000000..a3eb8d7 --- /dev/null +++ b/.github/workflows/reusable-pages-deployment.yml @@ -0,0 +1,123 @@ +name: Reusable - Pages deployment + +# The caller grants the deployment job "pages: write" and "id-token: write", and the repository publishes its Pages from GitHub Actions. +on: + workflow_call: + inputs: + build-command: + description: Commands building the site into site-path, run from the working directory + type: string + required: true + deploy: + description: Deploy the site once built, false building it only, to check a pull request for instance + type: boolean + required: false + default: true + fetch-depth: + description: Number of commits to clone, 0 for the whole history a site reading the date of each page needs + type: number + required: false + default: 1 + job-name: + description: Job name + type: string + required: false + default: Build + node-version: + description: Version of Node.js to install, with the npm cache of the lock file, none when empty + type: string + required: false + default: "" + operating-system: + description: Operating system executing the runner + type: string + required: false + default: ubuntu-latest + site-path: + description: Directory the build writes the site to, relative to the working directory + type: string + required: true + task-enabled: + description: Install Task (taskfile.dev) for the build command + type: boolean + required: false + default: false + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 30 + workflow-parts-version: + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." + type: string + required: false + default: "main" + working-directory: + description: Working directory + type: string + required: false + default: "." + outputs: + page-url: + description: URL of the deployed site + value: ${{ jobs.pages-deploy.outputs.page-url }} + +jobs: + pages-build: + name: ${{ inputs.job-name }} + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: ${{ inputs.fetch-depth }} + - name: Checkout workflow parts + if: inputs.task-enabled + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devpro/github-workflow-parts + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} + path: workflow-parts + - name: Setup Task + if: inputs.task-enabled + uses: ./workflow-parts/actions/task/setup + - name: Setup Node.js + if: inputs.node-version != '' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + cache: npm + cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json + - name: Build the site + run: | + ${{ inputs.build-command }} + - name: Verify the site was built + env: + SITE_PATH: ${{ inputs.site-path }} + run: test -s "${SITE_PATH}/index.html" + - name: Upload the site + if: inputs.deploy + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: ${{ inputs.working-directory }}/${{ inputs.site-path }} + + pages-deploy: + name: Deploy + if: inputs.deploy + needs: pages-build + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + outputs: + page-url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/.github/workflows/reusable-terraform-deployment.yml b/.github/workflows/reusable-terraform-deployment.yml index fb79f69..e4fa9a5 100644 --- a/.github/workflows/reusable-terraform-deployment.yml +++ b/.github/workflows/reusable-terraform-deployment.yml @@ -23,6 +23,11 @@ on: type: string required: false default: "ubuntu-latest" + destroy: + description: "Destroy the resources of the environment rather than apply the configuration" + type: boolean + required: false + default: false environment: description: "GitHub environment" type: string @@ -140,6 +145,9 @@ jobs: run: terraform validate - name: Terraform plan run: terraform plan -input=false -out=plan.tfplan ${{ inputs.terraform-var-flags }} + env: + # Read by Terraform as arguments of the plan command, the plan of a destruction being applied as any other. + TF_CLI_ARGS_plan: ${{ inputs.destroy && '-destroy' || '' }} - name: Terraform apply run: terraform apply -auto-approve plan.tfplan env: diff --git a/.istarci.yml b/.istarci.yml index 1f79de2..1469ca8 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -4,6 +4,9 @@ runner: # The .NET jobs need the SDK, which a hosted runner has and the default image does not. images: "test-dotnet-*": ghcr.io/devpro/ubuntu-dotnet:latest + # The npm test runs npm, and the Playwright one needs the system packages of the browser, which a local run cannot install without root. + test-npm-publish: ghcr.io/devpro/debian-node:latest + test-playwright-install: ghcr.io/devpro/debian-node-playwright:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] # The Sonar path of the .NET quality workflow runs against a SonarQube of this machine, which GitHub does not have. diff --git a/README.md b/README.md index 4212f56..a3207ed 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,21 @@ Containers: - [Ensure the quality of the code](.github/workflows/reusable-dotnet-quality.yml) +Git: + +- [Tell what changed, the version and the branch slug, for the jobs that follow](.github/workflows/reusable-git-check.yml) + +GitHub Pages: + +- [Build a site and deploy it](.github/workflows/reusable-pages-deployment.yml) + Markup (Markdown, YAML): - [Ensure the quality of the content](.github/workflows/reusable-markup-lint.yml) Terraform: -- [Deploy an environment](.github/workflows/reusable-terraform-deployment.yml) +- [Deploy or destroy an environment](.github/workflows/reusable-terraform-deployment.yml) - [Ensure the quality of the infrastructure code](.github/workflows/reusable-terraform-quality.yml) ## Composite actions @@ -51,6 +59,10 @@ Containers: - [Install the SDK, lint the code and restore NuGet packages](actions/dotnet/install-lint-restore/action.yml) - [Package the projects and push them to a NuGet registry](actions/dotnet/pack-push/action.yml) +Git: + +- [Tell whether a commit changes some paths](actions/git/check-changes/action.yml) + Go: - [Setup GoReleaser](actions/goreleaser/setup/action.yml) @@ -69,10 +81,19 @@ MongoDB: - [Add the runner IP address to the Atlas access list](actions/mongodb-atlas/add-runner-ip/action.yml) - [Start a server in the pipeline](actions/mongodb/start/action.yml) +Node.js: + +- [Publish a package unless its version already is](actions/npm/publish/action.yml) +- [Install the Playwright browsers](actions/playwright/install/action.yml) + SBOM: - [Generate a SBOM with Syft](actions/syft/generate-sbom/action.yml) +Task: + +- [Setup Task](actions/task/setup/action.yml) + Terraform: - [Setup Terraform](actions/terraform/setup/action.yml) diff --git a/actions/git/check-changes/action.yml b/actions/git/check-changes/action.yml new file mode 100644 index 0000000..2154b7b --- /dev/null +++ b/actions/git/check-changes/action.yml @@ -0,0 +1,49 @@ +name: Check changes +description: | + Tells whether the checked out commit changes any of the given paths, compared with a base: + the one given, or else the base of the pull request, or else the commit before the push. + A base the clone cannot read counts as a change, as on a new branch, a manual run or a shallow clone, so nothing is skipped by mistake. + The repository is checked out with its history (fetch-depth 0) for the base to be readable. + +inputs: + paths: + description: Paths to compare, separated by spaces (for example "src test") + required: true + base: + description: Commit or tree to compare with, the base of the pull request or the commit before the push when empty + required: false + default: "" + +outputs: + changed: + description: "\"true\" when a path changed, \"false\" otherwise" + value: ${{ steps.compare.outputs.changed }} + +runs: + using: "composite" + steps: + - name: Compare with the base + id: compare + shell: bash + env: + BASE: ${{ inputs.base || github.event.pull_request.base.sha || github.event.before }} + PATHS: ${{ inputs.paths }} + run: | + read -ra path_list <<< "$PATHS" + if [ -z "$BASE" ] || ! git cat-file -e "${BASE}^{tree}" 2>/dev/null; then + echo "No readable base to compare with, counted as a change" + echo "changed=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "Comparing ${PATHS} between ${BASE} and HEAD" + # The exit status tells a change (1) from a failure of git (above 1), which stops the step. + status=0 + git diff --quiet "$BASE" HEAD -- "${path_list[@]}" || status=$? + case "$status" in + 0) echo "changed=false" >> "$GITHUB_OUTPUT" ;; + 1) + git diff --name-status "$BASE" HEAD -- "${path_list[@]}" + echo "changed=true" >> "$GITHUB_OUTPUT" + ;; + *) exit "$status" ;; + esac diff --git a/actions/npm/publish/action.yml b/actions/npm/publish/action.yml new file mode 100644 index 0000000..98e9b86 --- /dev/null +++ b/actions/npm/publish/action.yml @@ -0,0 +1,53 @@ +name: Publish an npm package +description: | + Publishes the package of the working directory unless its version is already on the registry, + which refuses to publish over it, so a push that did not bump the version is skipped rather than failed. + The registry and the credentials are those of the .npmrc that actions/setup-node writes with registry-url: + a token given as NODE_AUTH_TOKEN, or the OIDC token of npm trusted publishing (permission id-token write). + +inputs: + publish-command: + description: Command publishing the package, "pnpm publish --no-git-checks" for a pnpm workspace for instance + required: false + default: "npm publish" + working-directory: + description: Directory holding the package.json of the package + required: false + default: "." + +outputs: + published: + description: "\"true\" when the package was published, \"false\" when its version already was" + value: ${{ steps.registry.outputs.exists == 'false' && 'true' || 'false' }} + version: + description: Version of the package, read from its package.json + value: ${{ steps.registry.outputs.version }} + +runs: + using: "composite" + steps: + - name: Check whether the version is already published + id: registry + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + name=$(node -p "require('./package.json').name") + version=$(node -p "require('./package.json').version") + echo "version=${version}" >> "$GITHUB_OUTPUT" + # npm answers E404 for a package or a version the registry does not have, and anything else is a failure to read it. + errors=$(mktemp) + if npm view "${name}@${version}" version > /dev/null 2> "$errors"; then + echo "${name}@${version} is already published, nothing to do" + echo "exists=true" >> "$GITHUB_OUTPUT" + elif grep -q E404 "$errors"; then + echo "${name}@${version} is not published yet" + echo "exists=false" >> "$GITHUB_OUTPUT" + else + cat "$errors" >&2 + exit 1 + fi + - name: Publish the package + if: steps.registry.outputs.exists == 'false' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: ${{ inputs.publish-command }} diff --git a/actions/playwright/install/action.yml b/actions/playwright/install/action.yml new file mode 100644 index 0000000..7e84d18 --- /dev/null +++ b/actions/playwright/install/action.yml @@ -0,0 +1,36 @@ +name: Install Playwright browsers +description: | + Installs the browsers of the Playwright version the project depends on, with their system packages on a hosted runner. + The browser build has to match that version, so it is installed after the dependencies (npm ci) rather than baked into an image. + System packages need root, which a hosted runner gets through sudo, and a container job's image already installed them. + +inputs: + browsers: + description: Browsers to install, separated by spaces + required: false + default: chromium + working-directory: + description: Directory of the project depending on Playwright + required: false + default: "." + +runs: + using: "composite" + steps: + - name: Install the system packages of the browsers + if: runner.environment == 'github-hosted' + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + BROWSERS: ${{ inputs.browsers }} + run: | + read -ra browser_list <<< "$BROWSERS" + npx --no-install playwright install-deps "${browser_list[@]}" + - name: Install the browsers + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + BROWSERS: ${{ inputs.browsers }} + run: | + read -ra browser_list <<< "$BROWSERS" + npx --no-install playwright install "${browser_list[@]}" diff --git a/actions/task/setup/action.yml b/actions/task/setup/action.yml new file mode 100644 index 0000000..1d5a75a --- /dev/null +++ b/actions/task/setup/action.yml @@ -0,0 +1,44 @@ +name: Setup Task +description: | + Installs Task (taskfile.dev) by downloading the official binary from its GitHub release and verifying its SHA256 checksum + (replacement for go-task/setup-task and for an install through npm, to reduce supply-chain risk). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + task-version: + description: Version of Task to install (check latest from https://github.com/go-task/task/releases) + required: false + default: "v3.54.0" + +runs: + using: "composite" + steps: + - name: Install Task + shell: bash + env: + TASK_VERSION: ${{ inputs.task-version }} + TASK_TARBALL: task_linux_${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }}.tar.gz + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + BASE_URL="https://github.com/go-task/task/releases/download/${TASK_VERSION}" + + echo "Downloading Task binary and checksums..." + curl -fsSL -O "${BASE_URL}/${TASK_TARBALL}" + curl -fsSL -O "${BASE_URL}/task_checksums.txt" + + echo "Verifying checksum..." + if grep " ${TASK_TARBALL}$" task_checksums.txt | sha256sum --check --status; then + echo "Verification successful!" + tar -xzf "${TASK_TARBALL}" task + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + mv task "${RUNNER_TEMP}/bin/task" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + export PATH="${RUNNER_TEMP}/bin:$PATH" + rm -f "${TASK_TARBALL}" task_checksums.txt + task --version + else + echo "ERROR: Checksum verification failed!" >&2 + exit 1 + fi diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py index 2af5f48..5083418 100755 --- a/test/check_workflow_parts.py +++ b/test/check_workflow_parts.py @@ -20,8 +20,10 @@ # Inputs whose value is shell code or shell words by contract, so they are written into the script as they are. SHELL_INPUTS = { + "inputs.build-command", "inputs.custom-commands", "inputs.dotnet-test-args", + "inputs.publish-command", "inputs.terraform-var-flags", } diff --git a/test/fixtures/npm/published/package.json b/test/fixtures/npm/published/package.json new file mode 100644 index 0000000..52edf7f --- /dev/null +++ b/test/fixtures/npm/published/package.json @@ -0,0 +1,4 @@ +{ + "name": "left-pad", + "version": "1.3.0" +} diff --git a/test/fixtures/npm/unpublished/index.js b/test/fixtures/npm/unpublished/index.js new file mode 100644 index 0000000..65f9b92 --- /dev/null +++ b/test/fixtures/npm/unpublished/index.js @@ -0,0 +1 @@ +module.exports = "fixture"; diff --git a/test/fixtures/npm/unpublished/package.json b/test/fixtures/npm/unpublished/package.json new file mode 100644 index 0000000..9096535 --- /dev/null +++ b/test/fixtures/npm/unpublished/package.json @@ -0,0 +1,5 @@ +{ + "name": "@devpro/github-workflow-parts-fixture", + "version": "0.0.0", + "files": ["index.js"] +} diff --git a/test/fixtures/pages/.gitignore b/test/fixtures/pages/.gitignore new file mode 100644 index 0000000..57510a2 --- /dev/null +++ b/test/fixtures/pages/.gitignore @@ -0,0 +1 @@ +_site/ diff --git a/test/fixtures/pages/build.js b/test/fixtures/pages/build.js new file mode 100644 index 0000000..38db323 --- /dev/null +++ b/test/fixtures/pages/build.js @@ -0,0 +1,4 @@ +// Writes the site the Pages deployment workflow checks, as a site generator would. +const fs = require("node:fs"); +fs.mkdirSync("_site", { recursive: true }); +fs.writeFileSync("_site/index.html", "Fixture\n"); diff --git a/test/fixtures/pages/package-lock.json b/test/fixtures/pages/package-lock.json new file mode 100644 index 0000000..a05fe2d --- /dev/null +++ b/test/fixtures/pages/package-lock.json @@ -0,0 +1,10 @@ +{ + "name": "pages-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pages-fixture" + } + } +} diff --git a/test/fixtures/pages/package.json b/test/fixtures/pages/package.json new file mode 100644 index 0000000..fe0260c --- /dev/null +++ b/test/fixtures/pages/package.json @@ -0,0 +1,7 @@ +{ + "name": "pages-fixture", + "private": true, + "scripts": { + "build": "node build.js" + } +} diff --git a/test/fixtures/playwright/package-lock.json b/test/fixtures/playwright/package-lock.json new file mode 100644 index 0000000..0748bb5 --- /dev/null +++ b/test/fixtures/playwright/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "playwright-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "playwright-fixture", + "dependencies": { + "playwright": "1.63.0" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/test/fixtures/playwright/package.json b/test/fixtures/playwright/package.json new file mode 100644 index 0000000..e7c34c4 --- /dev/null +++ b/test/fixtures/playwright/package.json @@ -0,0 +1,7 @@ +{ + "name": "playwright-fixture", + "private": true, + "dependencies": { + "playwright": "1.63.0" + } +} diff --git a/test/fixtures/version/Directory.Build.props b/test/fixtures/version/Directory.Build.props new file mode 100644 index 0000000..e62eab6 --- /dev/null +++ b/test/fixtures/version/Directory.Build.props @@ -0,0 +1,5 @@ + + + 1.2.3 + + From 1407a4bb9c27be25216e3ab69865c3895d862107 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 00:05:05 +0200 Subject: [PATCH 27/55] Name the git check test job as the job it calls, under which a local run reads its outputs Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c286a5b..3506c97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -576,7 +576,8 @@ jobs: [ "$UNCHANGED" = false ] [ "$UNREADABLE" = true ] - test-git-check: + # A local run reads the outputs of a called workflow under the id of the job inside it, which the caller shares. + git-check: name: "Test / Git check reusable workflow" uses: ./.github/workflows/reusable-git-check.yml with: @@ -585,15 +586,15 @@ jobs: test-git-check-outputs: name: "Test / Git check reusable workflow outputs" - needs: test-git-check + needs: git-check runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Verify the outputs env: - BRANCH_SLUG: ${{ needs.test-git-check.outputs.branch-slug }} - CHANGED: ${{ needs.test-git-check.outputs.changed }} - VERSION_MAJOR_MINOR: ${{ needs.test-git-check.outputs.version-major-minor }} + BRANCH_SLUG: ${{ needs.git-check.outputs.branch-slug }} + CHANGED: ${{ needs.git-check.outputs.changed }} + VERSION_MAJOR_MINOR: ${{ needs.git-check.outputs.version-major-minor }} run: | [ "$VERSION_MAJOR_MINOR" = 1.2 ] [[ "$BRANCH_SLUG" =~ ^[a-z0-9-]+$ ]] From c6d06a61a377a41dc192ce58f08a0b5100d9a2bc Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 00:52:56 +0200 Subject: [PATCH 28/55] Compare with the parent commit when the event names no base, as a local run or a manual one Co-Authored-By: Claude Opus 5.5 --- actions/git/check-changes/action.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/actions/git/check-changes/action.yml b/actions/git/check-changes/action.yml index 2154b7b..6e795fe 100644 --- a/actions/git/check-changes/action.yml +++ b/actions/git/check-changes/action.yml @@ -1,8 +1,8 @@ name: Check changes description: | Tells whether the checked out commit changes any of the given paths, compared with a base: - the one given, or else the base of the pull request, or else the commit before the push. - A base the clone cannot read counts as a change, as on a new branch, a manual run or a shallow clone, so nothing is skipped by mistake. + the one given, or else the base of the pull request, or else the commit before the push, or else the parent commit. + A base the clone cannot read counts as a change, as on a new branch or in a shallow clone, so nothing is skipped by mistake. The repository is checked out with its history (fetch-depth 0) for the base to be readable. inputs: @@ -10,7 +10,7 @@ inputs: description: Paths to compare, separated by spaces (for example "src test") required: true base: - description: Commit or tree to compare with, the base of the pull request or the commit before the push when empty + description: Commit or tree to compare with, the base of the pull request, the commit before the push or the parent commit when empty required: false default: "" @@ -30,7 +30,9 @@ runs: PATHS: ${{ inputs.paths }} run: | read -ra path_list <<< "$PATHS" - if [ -z "$BASE" ] || ! git cat-file -e "${BASE}^{tree}" 2>/dev/null; then + # An event naming no base, a manual run or a local one, is compared with the parent commit. + BASE="${BASE:-HEAD~1}" + if ! git cat-file -e "${BASE}^{tree}" 2>/dev/null; then echo "No readable base to compare with, counted as a change" echo "changed=true" >> "$GITHUB_OUTPUT" exit 0 From 86258d301cd7d6d374587a0663ee384b9ccc4515 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 01:14:02 +0200 Subject: [PATCH 29/55] Read the inputs of the .NET quality workflow only, the test filter being part of the test arguments A variable the caller did not set is empty on GitHub, but a placeholder in a local run, which reached the test applications as an argument. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CQAQGtomMGWhkmqVEuUJLR --- .github/workflows/ci.yml | 7 +++---- .github/workflows/reusable-dotnet-quality.yml | 19 +++++++------------ test/fixtures/dotnet/LeftOutTests.cs | 4 ++-- 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3506c97..e1c08e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -478,8 +478,7 @@ jobs: with: working-directory: test/fixtures/dotnet # The fixture holds a test that fails, which only the filter keeps the job from running. - dotnet-test-filter: --filter-not-namespace Fixture.Tests.LeftOut* - dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace Fixture.Tests.LeftOut* # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. test-dotnet-quality-sonar: @@ -488,9 +487,9 @@ jobs: uses: ./.github/workflows/reusable-dotnet-quality.yml with: working-directory: test/fixtures/dotnet - dotnet-test-filter: --filter-not-namespace Fixture.Tests.LeftOut* - dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace Fixture.Tests.LeftOut* sonar-enabled: true + sonar-host-url: ${{ vars.SONAR_HOST_URL }} sonar-project-key: github-workflow-parts-fixture sonar-project-name: GitHub workflow parts fixture secrets: diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 1ef3e11..b114f13 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -13,11 +13,6 @@ on: type: string required: false default: "" - dotnet-test-filter: - description: "Arguments appended to the test arguments to leave tests out (for example \"--filter-not-namespace App.IntegrationTests*\"), the caller's DOTNET_TEST_FILTER variable when empty" - type: string - required: false - default: "" dotnet-version: description: .NET version type: string @@ -29,7 +24,7 @@ on: required: false default: "" fossa-enabled: - description: Enable license compliance with FOSSA, unless the caller's FOSSA_ENABLED variable is "false" + description: Enable license compliance with FOSSA type: boolean required: false default: false @@ -74,10 +69,10 @@ on: required: false default: "node_modules" sonar-host-url: - description: Sonar host URL, the caller's SONAR_HOST_URL variable when empty, then SonarCloud + description: Sonar host URL type: string required: false - default: "" + default: "https://sonarcloud.io" sonar-organization: description: Sonar organization type: string @@ -175,24 +170,24 @@ jobs: if: ${{ ! inputs.sonar-enabled }} uses: ./workflow-parts/actions/dotnet/build-test with: - dotnet-test-args: ${{ format('{0} {1}', inputs.dotnet-test-args, inputs.dotnet-test-filter || vars.DOTNET_TEST_FILTER) }} + dotnet-test-args: ${{ inputs.dotnet-test-args }} working-directory: ${{ inputs.working-directory }} - name: Build, test & analyze if: ${{ inputs.sonar-enabled }} uses: ./workflow-parts/actions/dotnet/build-test-sonar with: - dotnet-test-args: ${{ format('{0} {1}', inputs.dotnet-test-args, inputs.dotnet-test-filter || vars.DOTNET_TEST_FILTER) }} + dotnet-test-args: ${{ inputs.dotnet-test-args }} working-directory: ${{ inputs.working-directory }} sonar-cpd-exclusions: ${{ inputs.sonar-cpd-exclusions }} sonar-exclusions: ${{ inputs.sonar-exclusions }} - sonar-host-url: ${{ inputs.sonar-host-url || vars.SONAR_HOST_URL || 'https://sonarcloud.io' }} + sonar-host-url: ${{ inputs.sonar-host-url }} sonar-organization: ${{ inputs.sonar-organization }} sonar-project-key: ${{ inputs.sonar-project-key }} sonar-project-name: ${{ inputs.sonar-project-name }} sonar-token: ${{ secrets.sonar-token }} - name: Check license compliance with FOSSA # A repository turns it off where FOSSA cannot be reached, a local run holding no API key. - if: ${{ inputs.fossa-enabled && vars.FOSSA_ENABLED != 'false' }} + if: ${{ inputs.fossa-enabled }} uses: ./workflow-parts/actions/fossa/analyze # https://status.fossa.com/ continue-on-error: true diff --git a/test/fixtures/dotnet/LeftOutTests.cs b/test/fixtures/dotnet/LeftOutTests.cs index 11f7b44..d84a124 100644 --- a/test/fixtures/dotnet/LeftOutTests.cs +++ b/test/fixtures/dotnet/LeftOutTests.cs @@ -2,9 +2,9 @@ namespace Fixture.Tests.LeftOut; -// Fails on purpose: the CI job passes only when dotnet-test-filter leaves this namespace out. +// Fails on purpose: the CI job passes only when the test filter leaves this namespace out. public class LeftOutTests { [Fact] - public void FailsUnlessLeftOut() => Assert.Fail("dotnet-test-filter did not leave this test out"); + public void FailsUnlessLeftOut() => Assert.Fail("the test filter did not leave this test out"); } From 5f0fa01fb5bf4685d6e29da1acae759f590eadf7 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 23:35:53 +0200 Subject: [PATCH 30/55] Add the Node.js quality and npm publication workflows, for repositories to call rather than pin actions of their own Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VTVYojWrgtV6aKFgf2yjU2 --- .github/workflows/ci.yml | 52 +++++ .github/workflows/reusable-node-quality.yml | 190 ++++++++++++++++++ .../workflows/reusable-npm-publication.yml | 140 +++++++++++++ .istarci.yml | 3 + README.md | 5 + test/check_workflow_parts.py | 1 + test/fixtures/pnpm/package.json | 4 + test/fixtures/pnpm/pnpm-lock.yaml | 9 + 8 files changed, 404 insertions(+) create mode 100644 .github/workflows/reusable-node-quality.yml create mode 100644 .github/workflows/reusable-npm-publication.yml create mode 100644 test/fixtures/pnpm/package.json create mode 100644 test/fixtures/pnpm/pnpm-lock.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1c08e5..37933aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -660,3 +660,55 @@ jobs: site-path: _site task-enabled: true working-directory: test/fixtures/pages + + test-node-quality-npm: + name: "Test / Node.js quality reusable workflow, with npm" + uses: ./.github/workflows/reusable-node-quality.yml + with: + artifact-name: node-quality-npm + artifact-path: test/fixtures/playwright/package.json + extra-vars: | + FIXTURE_VALUE=extra + playwright-directory: test/fixtures/playwright + task-enabled: true + terraform-version: 1.16.6 + test-command: | + [ "$FIXTURE_VALUE" = extra ] + task --version + terraform version + node -e "require('playwright').chromium.launch().then((browser) => browser.close())" + # The commands see the repository alone, without the workflow parts checked out beside it. + [ ! -e "${GITHUB_WORKSPACE}/workflow-parts" ] + working-directory: test/fixtures/playwright + + test-node-quality-pnpm: + name: "Test / Node.js quality reusable workflow, with pnpm" + uses: ./.github/workflows/reusable-node-quality.yml + with: + pnpm-version: "12" + test-command: | + [[ "$(pnpm --version)" == 12.* ]] + working-directory: test/fixtures/pnpm + + test-npm-publication: + name: "Test / npm publication reusable workflow, without publishing" + uses: ./.github/workflows/reusable-npm-publication.yml + with: + build-command: test -f test/fixtures/npm/unpublished/index.js + # A dry run goes through every step of a publication but the upload, which needs no credentials. + publish-command: npm publish --dry-run + working-directory: test/fixtures/npm/unpublished + + test-npm-publication-outputs: + name: "Test / npm publication reusable workflow outputs" + needs: test-npm-publication + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Verify the package was published + env: + PUBLISHED: ${{ needs.test-npm-publication.outputs.published }} + VERSION: ${{ needs.test-npm-publication.outputs.version }} + run: | + [ "$PUBLISHED" = true ] + [ "$VERSION" = 0.0.0 ] diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml new file mode 100644 index 0000000..f806b98 --- /dev/null +++ b/.github/workflows/reusable-node-quality.yml @@ -0,0 +1,190 @@ +name: Reusable - Node.js quality + +on: + workflow_call: + inputs: + artifact-name: + description: Name of the artifact keeping artifact-path once the commands ran, whatever their result + type: string + required: false + default: test-results + artifact-path: + description: Paths of the reports to keep, one per line, relative to the root of the repository, none when empty + type: string + required: false + default: "" + extra-vars: + description: Environment variables of the commands, one KEY=value per line + type: string + required: false + default: "" + fetch-depth: + description: Number of commits to clone, 0 for the whole history an analysis blaming each line needs + type: number + required: false + default: 1 + job-name: + description: Job name + type: string + required: false + default: Quality + node-version: + description: Version of Node.js to install + type: string + required: false + default: "24" + operating-system: + description: Operating system executing the runner + type: string + required: false + default: ubuntu-latest + playwright-directory: + description: Directory of the project depending on Playwright, relative to the root of the repository, whose browsers are installed once the dependencies are, none when empty + type: string + required: false + default: "" + pnpm-version: + description: Version of pnpm installing the dependencies from pnpm-lock.yaml, npm installing them from package-lock.json when empty + type: string + required: false + default: "" + task-enabled: + description: Install Task (taskfile.dev) for the commands + type: boolean + required: false + default: false + terraform-version: + description: Version of Terraform to install for the commands, none when empty + type: string + required: false + default: "" + test-command: + description: Commands linting, building and testing the code, run from the working directory once the dependencies are installed + type: string + required: true + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 30 + workflow-parts-version: + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." + type: string + required: false + default: "main" + working-directory: + description: Directory holding the package.json and the lock file + type: string + required: false + default: "." + secrets: + additional-vars: + description: Secret environment variables of the commands, one KEY=value per line, masked in the log + required: false + +jobs: + node-quality: + name: ${{ inputs.job-name }} + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + defaults: + run: + working-directory: ${{ inputs.working-directory }} + steps: + - name: Set additional variables + env: + EXTRA_VARS: ${{ inputs.extra-vars }} + run: | + if [[ -n "$EXTRA_VARS" ]]; then + echo "$EXTRA_VARS" >> "$GITHUB_ENV" + fi + - name: Set additional secrets + env: + ADDITIONAL_VARS: ${{ secrets.additional-vars }} + run: | + if [[ -n "$ADDITIONAL_VARS" ]]; then + echo "$ADDITIONAL_VARS" | while IFS='=' read -r _ val; do + if [[ -n "$val" ]]; then + echo "::add-mask::$val" + fi + done + echo "$ADDITIONAL_VARS" >> "$GITHUB_ENV" + fi + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: ${{ inputs.fetch-depth }} + - name: Checkout workflow parts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devpro/github-workflow-parts + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} + path: workflow-parts + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + # The npm cache is keyed on the lock file, which a package without dependencies does not have. + cache: ${{ inputs.pnpm-version == '' && hashFiles(format('{0}/package-lock.json', inputs.working-directory)) != '' && 'npm' || '' }} + cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json + # pnpm installed with the npm of the Node set up above, rather than through a third party action. + - name: Setup pnpm + id: pnpm + if: inputs.pnpm-version != '' + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} + run: | + # An image already carrying that version, as a container job's may, needs no install. + current=$(pnpm --version 2>/dev/null || true) + if [[ "$current" != "$PNPM_VERSION" && "$current" != "$PNPM_VERSION".* ]]; then + npm install --global "pnpm@${PNPM_VERSION}" + fi + echo "store-path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + - name: Cache pnpm store + if: inputs.pnpm-version != '' + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ steps.pnpm.outputs.store-path }} + key: pnpm-${{ runner.os }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', inputs.working-directory)) }} + restore-keys: pnpm-${{ runner.os }}- + - name: Install dependencies + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} + run: | + if [[ -n "$PNPM_VERSION" ]]; then + pnpm install --frozen-lockfile + elif [[ -f package-lock.json ]]; then + npm ci + else + echo "No package-lock.json, nothing to install" + fi + - name: Setup Task + if: inputs.task-enabled + uses: ./workflow-parts/actions/task/setup + - name: Setup Terraform + if: inputs.terraform-version != '' + uses: ./workflow-parts/actions/terraform/setup + with: + terraform-version: ${{ inputs.terraform-version }} + - name: Install Playwright browsers + if: inputs.playwright-directory != '' + uses: ./workflow-parts/actions/playwright/install + with: + working-directory: ${{ inputs.playwright-directory }} + # The commands lint, format and scan the repository, which the workflow parts are no part of. + - name: Remove the workflow parts + working-directory: . + run: rm -rf workflow-parts + - name: Lint, build and test + run: | + ${{ inputs.test-command }} + - name: Upload the reports + if: always() && inputs.artifact-path != '' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/reusable-npm-publication.yml b/.github/workflows/reusable-npm-publication.yml new file mode 100644 index 0000000..fb67664 --- /dev/null +++ b/.github/workflows/reusable-npm-publication.yml @@ -0,0 +1,140 @@ +name: Reusable - npm publication + +# The caller grants "packages: write" to publish to GitHub Packages, or "id-token: write" for npm trusted publishing to npmjs.org. +on: + workflow_call: + inputs: + build-command: + description: Commands building and checking the package before it is published, run from the root of the repository, none when empty + type: string + required: false + default: "" + job-name: + description: Job name + type: string + required: false + default: Publication + node-version: + description: Version of Node.js to install + type: string + required: false + default: "24" + operating-system: + description: Operating system executing the runner + type: string + required: false + default: ubuntu-latest + pnpm-version: + description: Version of pnpm installing the dependencies from pnpm-lock.yaml, npm installing them from package-lock.json when empty + type: string + required: false + default: "" + publish-command: + description: Command publishing the package, "pnpm publish --no-git-checks" for a pnpm workspace for instance + type: string + required: false + default: "npm publish" + registry-url: + description: Registry to publish to, GitHub Packages (https://npm.pkg.github.com) being authenticated with the token of the run, npmjs.org with the OIDC token of trusted publishing + type: string + required: false + default: "https://registry.npmjs.org" + scope: + description: Scope of the package, "@devpro" for instance, which GitHub Packages requires + type: string + required: false + default: "" + timeout-minutes: + description: Minutes the job may run before it is cancelled + type: number + required: false + default: 20 + workflow-parts-version: + description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." + type: string + required: false + default: "main" + working-directory: + description: Directory holding the package.json of the package, relative to the root of the repository + type: string + required: false + default: "." + outputs: + published: + description: "\"true\" when the package was published, \"false\" when its version already was" + value: ${{ jobs.npm-publication.outputs.published }} + version: + description: Version of the package, read from its package.json + value: ${{ jobs.npm-publication.outputs.version }} + +jobs: + npm-publication: + name: ${{ inputs.job-name }} + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + published: ${{ steps.github-packages.outputs.published || steps.npmjs.outputs.published }} + version: ${{ steps.github-packages.outputs.version || steps.npmjs.outputs.version }} + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout workflow parts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: devpro/github-workflow-parts + # The caller being this repository, the actions under test are those of the commit under test. + ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} + path: workflow-parts + persist-credentials: false + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ inputs.node-version }} + # Writes the .npmrc that npm publish and npm view authenticate with. + registry-url: ${{ inputs.registry-url }} + scope: ${{ inputs.scope }} + # A cache restored into a publication could carry what another branch put in it. + package-manager-cache: false + # pnpm installed with the npm of the Node set up above, rather than through a third party action. + - name: Setup pnpm + if: inputs.pnpm-version != '' + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} + run: | + # An image already carrying that version, as a container job's may, needs no install. + current=$(pnpm --version 2>/dev/null || true) + if [[ "$current" != "$PNPM_VERSION" && "$current" != "$PNPM_VERSION".* ]]; then + npm install --global "pnpm@${PNPM_VERSION}" + fi + - name: Install dependencies + env: + PNPM_VERSION: ${{ inputs.pnpm-version }} + run: | + if [[ -n "$PNPM_VERSION" ]]; then + pnpm install --frozen-lockfile + elif [[ -f package-lock.json ]]; then + npm ci + else + echo "No package-lock.json, nothing to install" + fi + - name: Build the package + if: inputs.build-command != '' + run: | + ${{ inputs.build-command }} + - name: Publish to GitHub Packages + id: github-packages + if: contains(inputs.registry-url, 'npm.pkg.github.com') + uses: ./workflow-parts/actions/npm/publish + with: + publish-command: ${{ inputs.publish-command }} + working-directory: ${{ inputs.working-directory }} + env: + NODE_AUTH_TOKEN: ${{ github.token }} + # npm trusted publishing: the package is published with a short-lived OIDC token rather than a stored secret. + - name: Publish to npmjs.org + id: npmjs + if: ${{ !contains(inputs.registry-url, 'npm.pkg.github.com') }} + uses: ./workflow-parts/actions/npm/publish + with: + publish-command: ${{ inputs.publish-command }} + working-directory: ${{ inputs.working-directory }} diff --git a/.istarci.yml b/.istarci.yml index 1469ca8..13b2792 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -7,6 +7,9 @@ runner: # The npm test runs npm, and the Playwright one needs the system packages of the browser, which a local run cannot install without root. test-npm-publish: ghcr.io/devpro/debian-node:latest test-playwright-install: ghcr.io/devpro/debian-node-playwright:latest + test-node-quality-npm: ghcr.io/devpro/debian-node-playwright:latest + test-node-quality-pnpm: ghcr.io/devpro/debian-node:latest + test-npm-publication: ghcr.io/devpro/debian-node:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] # The Sonar path of the .NET quality workflow runs against a SonarQube of this machine, which GitHub does not have. diff --git a/README.md b/README.md index a3207ed..ecec527 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ Markup (Markdown, YAML): - [Ensure the quality of the content](.github/workflows/reusable-markup-lint.yml) +Node.js: + +- [Lint, build and test the code](.github/workflows/reusable-node-quality.yml) +- [Publish a package to npmjs.org or GitHub Packages](.github/workflows/reusable-npm-publication.yml) + Terraform: - [Deploy or destroy an environment](.github/workflows/reusable-terraform-deployment.yml) diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py index 5083418..0fff9b7 100755 --- a/test/check_workflow_parts.py +++ b/test/check_workflow_parts.py @@ -25,6 +25,7 @@ "inputs.dotnet-test-args", "inputs.publish-command", "inputs.terraform-var-flags", + "inputs.test-command", } # Parts no CI job can exercise, each with the reason, which the coverage check takes as known. diff --git a/test/fixtures/pnpm/package.json b/test/fixtures/pnpm/package.json new file mode 100644 index 0000000..448bd21 --- /dev/null +++ b/test/fixtures/pnpm/package.json @@ -0,0 +1,4 @@ +{ + "name": "pnpm-fixture", + "private": true +} diff --git a/test/fixtures/pnpm/pnpm-lock.yaml b/test/fixtures/pnpm/pnpm-lock.yaml new file mode 100644 index 0000000..9b60ae1 --- /dev/null +++ b/test/fixtures/pnpm/pnpm-lock.yaml @@ -0,0 +1,9 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} From a5c16aaa1f09e78b9e433dab470ae9661d9bc263 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 23:40:41 +0200 Subject: [PATCH 31/55] Publish the npm package in a single step, whose outputs the publication workflow gives as they are Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VTVYojWrgtV6aKFgf2yjU2 --- .../workflows/reusable-npm-publication.yml | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/.github/workflows/reusable-npm-publication.yml b/.github/workflows/reusable-npm-publication.yml index fb67664..fa9cbbd 100644 --- a/.github/workflows/reusable-npm-publication.yml +++ b/.github/workflows/reusable-npm-publication.yml @@ -73,8 +73,8 @@ jobs: runs-on: ${{ inputs.operating-system }} timeout-minutes: ${{ inputs.timeout-minutes }} outputs: - published: ${{ steps.github-packages.outputs.published || steps.npmjs.outputs.published }} - version: ${{ steps.github-packages.outputs.version || steps.npmjs.outputs.version }} + published: ${{ steps.publish.outputs.published }} + version: ${{ steps.publish.outputs.version }} steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -121,20 +121,12 @@ jobs: if: inputs.build-command != '' run: | ${{ inputs.build-command }} - - name: Publish to GitHub Packages - id: github-packages - if: contains(inputs.registry-url, 'npm.pkg.github.com') + # GitHub Packages takes the token of the run, and npmjs.org the short-lived OIDC token of trusted publishing, which npm asks for itself. + - name: Publish the package + id: publish uses: ./workflow-parts/actions/npm/publish with: publish-command: ${{ inputs.publish-command }} working-directory: ${{ inputs.working-directory }} env: - NODE_AUTH_TOKEN: ${{ github.token }} - # npm trusted publishing: the package is published with a short-lived OIDC token rather than a stored secret. - - name: Publish to npmjs.org - id: npmjs - if: ${{ !contains(inputs.registry-url, 'npm.pkg.github.com') }} - uses: ./workflow-parts/actions/npm/publish - with: - publish-command: ${{ inputs.publish-command }} - working-directory: ${{ inputs.working-directory }} + NODE_AUTH_TOKEN: ${{ contains(inputs.registry-url, 'npm.pkg.github.com') && github.token || '' }} From 55f99c3e0ee6a6255d766cdd327de79666f008cd Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 23:47:31 +0200 Subject: [PATCH 32/55] Cache npm for a site only when it has a lock file, give the site build secret variables, and run the steps before the clone from the root of the workspace Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VTVYojWrgtV6aKFgf2yjU2 --- .github/workflows/ci.yml | 4 ++++ .github/workflows/reusable-dotnet-quality.yml | 4 ++++ .github/workflows/reusable-node-quality.yml | 4 ++++ .../workflows/reusable-pages-deployment.yml | 23 +++++++++++++++++-- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 37933aa..b848ec9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -652,6 +652,7 @@ jobs: uses: ./.github/workflows/reusable-pages-deployment.yml with: build-command: | + [ "$FIXTURE_SECRET" = hidden ] task --version npm ci npm run build @@ -660,6 +661,9 @@ jobs: site-path: _site task-enabled: true working-directory: test/fixtures/pages + secrets: + additional-vars: | + FIXTURE_SECRET=hidden test-node-quality-npm: name: "Test / Node.js quality reusable workflow, with npm" diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index b114f13..d951bfa 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -120,6 +120,8 @@ jobs: steps: - name: Set additional variables shell: bash + # Run before the clone, which creates the working directory. + working-directory: . env: EXTRA_VARS: ${{ inputs.extra-vars }} run: | @@ -128,6 +130,8 @@ jobs: fi - name: Set additional secrets shell: bash + # Run before the clone, which creates the working directory. + working-directory: . env: ADDITIONAL_VARS: ${{ secrets.additional-vars }} run: | diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index f806b98..62503c3 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -92,6 +92,8 @@ jobs: working-directory: ${{ inputs.working-directory }} steps: - name: Set additional variables + # Run before the clone, which creates the working directory. + working-directory: . env: EXTRA_VARS: ${{ inputs.extra-vars }} run: | @@ -99,6 +101,8 @@ jobs: echo "$EXTRA_VARS" >> "$GITHUB_ENV" fi - name: Set additional secrets + # Run before the clone, which creates the working directory. + working-directory: . env: ADDITIONAL_VARS: ${{ secrets.additional-vars }} run: | diff --git a/.github/workflows/reusable-pages-deployment.yml b/.github/workflows/reusable-pages-deployment.yml index a3eb8d7..68ce4f8 100644 --- a/.github/workflows/reusable-pages-deployment.yml +++ b/.github/workflows/reusable-pages-deployment.yml @@ -24,7 +24,7 @@ on: required: false default: Build node-version: - description: Version of Node.js to install, with the npm cache of the lock file, none when empty + description: Version of Node.js to install, with the npm cache of the lock file when there is one, none when empty type: string required: false default: "" @@ -61,6 +61,10 @@ on: page-url: description: URL of the deployed site value: ${{ jobs.pages-deploy.outputs.page-url }} + secrets: + additional-vars: + description: Secret environment variables of the build command, one KEY=value per line, masked in the log + required: false jobs: pages-build: @@ -71,6 +75,20 @@ jobs: run: working-directory: ${{ inputs.working-directory }} steps: + - name: Set additional secrets + # Run before the clone, which creates the working directory. + working-directory: . + env: + ADDITIONAL_VARS: ${{ secrets.additional-vars }} + run: | + if [[ -n "$ADDITIONAL_VARS" ]]; then + echo "$ADDITIONAL_VARS" | while IFS='=' read -r _ val; do + if [[ -n "$val" ]]; then + echo "::add-mask::$val" + fi + done + echo "$ADDITIONAL_VARS" >> "$GITHUB_ENV" + fi - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -91,7 +109,8 @@ jobs: uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ inputs.node-version }} - cache: npm + # The npm cache is keyed on the lock file, which a site installing its tools globally does not have. + cache: ${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) != '' && 'npm' || '' }} cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json - name: Build the site run: | From f6f0403c1c8fbbedaf564fe53a3980f54d6c6249 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 20 Oct 2026 23:50:49 +0200 Subject: [PATCH 33/55] Read the coverage of a VSTest run, which writes it in a folder of its own Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VTVYojWrgtV6aKFgf2yjU2 --- actions/dotnet/build-test-sonar/action.yml | 5 +++-- actions/dotnet/build-test/action.yml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/actions/dotnet/build-test-sonar/action.yml b/actions/dotnet/build-test-sonar/action.yml index 9befddb..d7bcd46 100644 --- a/actions/dotnet/build-test-sonar/action.yml +++ b/actions/dotnet/build-test-sonar/action.yml @@ -128,10 +128,11 @@ runs: env: ASPNETCORE_ENVIRONMENT: Development REPORT_FOLDER: ${{ inputs.report-folder }} + # Microsoft.Testing.Platform writes the coverage beside the results, and VSTest in a folder of its own for each run. - name: Generate test report - if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} + if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/**/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} run: | - reportgenerator "-reports:$REPORT_FOLDER/TestResults/*.cobertura.xml" \ + reportgenerator "-reports:$REPORT_FOLDER/TestResults/**/*.cobertura.xml" \ "-targetdir:$REPORT_FOLDER" \ "-reporttypes:Cobertura;Html;TextSummary;SonarQube" shell: bash diff --git a/actions/dotnet/build-test/action.yml b/actions/dotnet/build-test/action.yml index 42f1c6f..125a979 100644 --- a/actions/dotnet/build-test/action.yml +++ b/actions/dotnet/build-test/action.yml @@ -35,10 +35,11 @@ runs: env: ASPNETCORE_ENVIRONMENT: Development REPORT_FOLDER: ${{ inputs.report-folder }} + # Microsoft.Testing.Platform writes the coverage beside the results, and VSTest in a folder of its own for each run. - name: Generate test report - if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} + if: ${{ always() && hashFiles(format('{0}/{1}/TestResults/**/*.cobertura.xml', inputs.working-directory, inputs.report-folder)) != '' }} run: | - reportgenerator "-reports:$REPORT_FOLDER/TestResults/*.cobertura.xml" \ + reportgenerator "-reports:$REPORT_FOLDER/TestResults/**/*.cobertura.xml" \ "-targetdir:$REPORT_FOLDER" \ "-reporttypes:Cobertura;Html;TextSummary" shell: bash From 000b2c31b046d6ac106f386e8295d1138fb22bbb Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 21 Oct 2026 13:28:10 +0200 Subject: [PATCH 34/55] Check an npm package with --dry-run when the run holds no credential for the registry, as a local run does, rather than failing on the registry refusing to answer Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 10 ++++++++++ actions/npm/publish/action.yml | 9 +++++++++ test/fixtures/npm/no-credential/.npmrc | 1 + test/fixtures/npm/no-credential/package.json | 5 +++++ 4 files changed, 25 insertions(+) create mode 100644 test/fixtures/npm/no-credential/.npmrc create mode 100644 test/fixtures/npm/no-credential/package.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b848ec9..27a7b2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -619,8 +619,16 @@ jobs: with: publish-command: npm publish --dry-run working-directory: test/fixtures/npm/unpublished + # GitHub Packages refuses to answer a run with no token about a package it holds, as a local run is, and answers E404 for one it does not. + - name: Publish with no credential + id: no-credential + uses: ./actions/npm/publish + with: + publish-command: touch "${RUNNER_TEMP}/packed" && npm publish + working-directory: test/fixtures/npm/no-credential - name: Verify only the new package was published env: + NO_CREDENTIAL: ${{ steps.no-credential.outputs.published }} PUBLISHED: ${{ steps.published.outputs.published }} PUBLISHED_VERSION: ${{ steps.published.outputs.version }} UNPUBLISHED: ${{ steps.unpublished.outputs.published }} @@ -628,6 +636,8 @@ jobs: [ "$PUBLISHED" = false ] [ "$PUBLISHED_VERSION" = 1.3.0 ] [ "$UNPUBLISHED" = true ] + [ "$NO_CREDENTIAL" = false ] + [ -f "${RUNNER_TEMP}/packed" ] test-playwright-install: name: "Test / Playwright install action" diff --git a/actions/npm/publish/action.yml b/actions/npm/publish/action.yml index 98e9b86..52a3e44 100644 --- a/actions/npm/publish/action.yml +++ b/actions/npm/publish/action.yml @@ -4,6 +4,7 @@ description: | which refuses to publish over it, so a push that did not bump the version is skipped rather than failed. The registry and the credentials are those of the .npmrc that actions/setup-node writes with registry-url: a token given as NODE_AUTH_TOKEN, or the OIDC token of npm trusted publishing (permission id-token write). + A run with neither, as a local one is, packs and checks the package with --dry-run added to the command, the registry refusing to answer it. inputs: publish-command: @@ -42,6 +43,9 @@ runs: elif grep -q E404 "$errors"; then echo "${name}@${version} is not published yet" echo "exists=false" >> "$GITHUB_OUTPUT" + elif grep -qE 'E401|ENEEDAUTH' "$errors" && [[ -z "${NODE_AUTH_TOKEN:-}" && -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]]; then + echo "No credential for the registry, so ${name}@${version} is packed and checked with --dry-run instead" + echo "exists=dry-run" >> "$GITHUB_OUTPUT" else cat "$errors" >&2 exit 1 @@ -51,3 +55,8 @@ runs: shell: bash working-directory: ${{ inputs.working-directory }} run: ${{ inputs.publish-command }} + - name: Check the package without publishing it + if: steps.registry.outputs.exists == 'dry-run' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: ${{ inputs.publish-command }} --dry-run diff --git a/test/fixtures/npm/no-credential/.npmrc b/test/fixtures/npm/no-credential/.npmrc new file mode 100644 index 0000000..6c871c2 --- /dev/null +++ b/test/fixtures/npm/no-credential/.npmrc @@ -0,0 +1 @@ +@devpro:registry=https://npm.pkg.github.com diff --git a/test/fixtures/npm/no-credential/package.json b/test/fixtures/npm/no-credential/package.json new file mode 100644 index 0000000..dc03bf4 --- /dev/null +++ b/test/fixtures/npm/no-credential/package.json @@ -0,0 +1,5 @@ +{ + "name": "@devpro/istarci", + "version": "0.0.0", + "files": ["package.json"] +} From c5074125f105b3265f62ec9d8f4d9fabbc0cc945 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 21 Oct 2026 14:23:34 +0200 Subject: [PATCH 35/55] Shape the Node.js quality workflow like the .NET one, with a Sonar analysis of its own and MongoDB as a replica set The steps follow the npm scripts a repository already has (lint, format:check, typecheck, build and the test script), each skipped when missing, rather than one step running every command given. Optional custom tests run once the code is built, Playwright browsers come with a project depending on Playwright, and the workflow parts are set aside while the scripts of the repository run. The Sonar scanner CLI analyzes JavaScript and TypeScript with their lcov coverage, which the scanner for .NET does not. The MongoDB action takes a port and a replica set, and prints its log when the server does not start. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 58 ++++-- .github/workflows/reusable-node-quality.yml | 197 +++++++++++++++----- .istarci.yml | 3 +- README.md | 6 +- actions/mongodb/start/action.yml | 44 ++++- actions/sonar/scan/action.yml | 112 +++++++++++ test/check_workflow_parts.py | 2 +- test/fixtures/node/package-lock.json | 40 ++++ test/fixtures/node/package.json | 15 ++ test/fixtures/node/record.js | 10 + test/fixtures/node/record.test.js | 6 + 11 files changed, 426 insertions(+), 67 deletions(-) create mode 100644 actions/sonar/scan/action.yml create mode 100644 test/fixtures/node/package-lock.json create mode 100644 test/fixtures/node/package.json create mode 100644 test/fixtures/node/record.js create mode 100644 test/fixtures/node/record.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27a7b2e..35afbe9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -401,6 +401,22 @@ jobs: run: | mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' mongodb://127.0.0.1:27017 | grep -qx 1 + test-mongodb-start-replica-set: + name: "Test / MongoDB start action, replica set" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Start MongoDB + uses: ./actions/mongodb/start + with: + port: "27117" + replica-set: rs0 + - name: Verify MongoDB is the primary of the replica set + run: | + mongosh --quiet --eval 'db.hello().isWritablePrimary' "mongodb://127.0.0.1:27117/?directConnection=true" | grep -qx true + test-syft-generate-sbom: name: "Test / Syft generate-sbom action" runs-on: ubuntu-latest @@ -679,31 +695,49 @@ jobs: name: "Test / Node.js quality reusable workflow, with npm" uses: ./.github/workflows/reusable-node-quality.yml with: - artifact-name: node-quality-npm - artifact-path: test/fixtures/playwright/package.json - extra-vars: | - FIXTURE_VALUE=extra - playwright-directory: test/fixtures/playwright - task-enabled: true - terraform-version: 1.16.6 - test-command: | + custom-tests: | [ "$FIXTURE_VALUE" = extra ] + mongosh --quiet --eval 'db.hello().isWritablePrimary' "mongodb://127.0.0.1:27017/?replicaSet=rs0" | grep -qx true task --version terraform version node -e "require('playwright').chromium.launch().then((browser) => browser.close())" - # The commands see the repository alone, without the workflow parts checked out beside it. + [ -f coverage/lcov.info ] + # Each script ran once, in that order, the missing ones being skipped. + diff <(printf '%s\n' lint format:check typecheck build test:coverage) report/scripts.log + # The custom tests see the repository alone, without the workflow parts checked out beside it. [ ! -e "${GITHUB_WORKSPACE}/workflow-parts" ] - working-directory: test/fixtures/playwright + extra-vars: | + FIXTURE_VALUE=extra + mongodb-enabled: true + task-enabled: true + terraform-version: 1.16.6 + test-script: test:coverage + working-directory: test/fixtures/node test-node-quality-pnpm: name: "Test / Node.js quality reusable workflow, with pnpm" uses: ./.github/workflows/reusable-node-quality.yml with: - pnpm-version: "12" - test-command: | + custom-tests: | [[ "$(pnpm --version)" == 12.* ]] + pnpm-version: "12" working-directory: test/fixtures/pnpm + # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. + test-node-quality-sonar: + name: "Test / Node.js quality reusable workflow with Sonar" + if: vars.SONAR_HOST_URL != '' + uses: ./.github/workflows/reusable-node-quality.yml + with: + sonar-enabled: true + sonar-host-url: ${{ vars.SONAR_HOST_URL }} + sonar-project-key: github-workflow-parts-node-fixture + sonar-project-name: GitHub workflow parts Node.js fixture + test-script: test:coverage + working-directory: test/fixtures/node + secrets: + sonar-token: ${{ secrets.SONAR_TOKEN }} + test-npm-publication: name: "Test / npm publication reusable workflow, without publishing" uses: ./.github/workflows/reusable-npm-publication.yml diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index 62503c3..fed83a6 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -3,33 +3,38 @@ name: Reusable - Node.js quality on: workflow_call: inputs: - artifact-name: - description: Name of the artifact keeping artifact-path once the commands ran, whatever their result - type: string - required: false - default: test-results - artifact-path: - description: Paths of the reports to keep, one per line, relative to the root of the repository, none when empty + custom-tests: + description: Optional shell commands testing what build & test produced, such as end-to-end tests against the built application type: string required: false default: "" extra-vars: - description: Environment variables of the commands, one KEY=value per line + description: "Additional environment variables at the start of the pipeline" type: string required: false default: "" - fetch-depth: - description: Number of commits to clone, 0 for the whole history an analysis blaming each line needs - type: number + fossa-enabled: + description: Enable license compliance with FOSSA + type: boolean + required: false + default: false + fossa-test: + description: Run FOSSA test on PR + type: boolean required: false - default: 1 + default: false job-name: description: Job name type: string required: false default: Quality + mongodb-enabled: + description: Start a local MongoDB server on 127.0.0.1:27017, as the single-node replica set rs0 transactions need, with mongosh on the path, before lint, build & test + type: boolean + required: false + default: false node-version: - description: Version of Node.js to install + description: Node.js version type: string required: false default: "24" @@ -38,48 +43,90 @@ on: type: string required: false default: ubuntu-latest - playwright-directory: - description: Directory of the project depending on Playwright, relative to the root of the repository, whose browsers are installed once the dependencies are, none when empty + pnpm-version: + description: Version of pnpm installing the dependencies from pnpm-lock.yaml, npm installing them from package-lock.json when empty type: string required: false default: "" - pnpm-version: - description: Version of pnpm installing the dependencies from pnpm-lock.yaml, npm installing them from package-lock.json when empty + sonar-enabled: + description: Enable check done by Sonar (SonarQube or SonarCloud) + type: boolean + required: false + default: false + sonar-coverage-paths: + description: lcov reports of the coverage the test script writes, relative to the working directory, separated by commas + type: string + required: false + default: "coverage/lcov.info" + sonar-cpd-exclusions: + description: Files or directories that should be excluded from Sonar code duplication analysis + type: string + required: false + default: "" + sonar-exclusions: + description: Files or directories that should be excluded from Sonar analysis + type: string + required: false + default: "**/node_modules/**" + sonar-host-url: + description: Sonar host URL + type: string + required: false + default: "https://sonarcloud.io" + sonar-organization: + description: Sonar organization + type: string + required: false + default: "" + sonar-project-key: + description: Sonar project key + type: string + required: false + default: "" + sonar-project-name: + description: Sonar project name type: string required: false default: "" task-enabled: - description: Install Task (taskfile.dev) for the commands + description: Install Task (taskfile.dev) for the scripts and the custom tests type: boolean required: false default: false terraform-version: - description: Version of Terraform to install for the commands, none when empty + description: Version of Terraform to install for the scripts and the custom tests, none when empty type: string required: false default: "" - test-command: - description: Commands linting, building and testing the code, run from the working directory once the dependencies are installed + test-script: + description: Script of package.json running the tests, for example "test:coverage" to write the coverage Sonar reads type: string - required: true + required: false + default: test timeout-minutes: description: Minutes the job may run before it is cancelled type: number required: false - default: 30 + default: 60 workflow-parts-version: - description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." + description: GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test. type: string required: false - default: "main" + default: main working-directory: description: Directory holding the package.json and the lock file type: string required: false default: "." secrets: + fossa-api-key: + description: FOSSA API KEY + required: false + sonar-token: + description: Sonar token for login + required: false additional-vars: - description: Secret environment variables of the commands, one KEY=value per line, masked in the log + description: "Additional variables" required: false jobs: @@ -89,6 +136,7 @@ jobs: timeout-minutes: ${{ inputs.timeout-minutes }} defaults: run: + shell: bash working-directory: ${{ inputs.working-directory }} steps: - name: Set additional variables @@ -117,7 +165,7 @@ jobs: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: ${{ inputs.fetch-depth }} + fetch-depth: 0 - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -154,6 +202,7 @@ jobs: key: pnpm-${{ runner.os }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', inputs.working-directory)) }} restore-keys: pnpm-${{ runner.os }}- - name: Install dependencies + id: install env: PNPM_VERSION: ${{ inputs.pnpm-version }} run: | @@ -164,31 +213,93 @@ jobs: else echo "No package-lock.json, nothing to install" fi + # The browsers have to match the Playwright version installed, so a project depending on it gets them without asking. + if [[ -x node_modules/.bin/playwright ]]; then + echo "playwright=true" >> "$GITHUB_OUTPUT" + fi + - name: Install Playwright browsers + if: steps.install.outputs.playwright == 'true' + uses: ./workflow-parts/actions/playwright/install + with: + working-directory: ${{ inputs.working-directory }} - name: Setup Task - if: inputs.task-enabled + if: ${{ inputs.task-enabled }} uses: ./workflow-parts/actions/task/setup - name: Setup Terraform if: inputs.terraform-version != '' uses: ./workflow-parts/actions/terraform/setup with: terraform-version: ${{ inputs.terraform-version }} - - name: Install Playwright browsers - if: inputs.playwright-directory != '' - uses: ./workflow-parts/actions/playwright/install + - name: Start MongoDB + if: ${{ inputs.mongodb-enabled }} + uses: ./workflow-parts/actions/mongodb/start with: - working-directory: ${{ inputs.playwright-directory }} - # The commands lint, format and scan the repository, which the workflow parts are no part of. - - name: Remove the workflow parts + replica-set: rs0 + # The scripts lint, format and test the repository, which the workflow parts are no part of. + - name: Set aside workflow parts working-directory: . - run: rm -rf workflow-parts - - name: Lint, build and test + run: mv workflow-parts "${RUNNER_TEMP}/workflow-parts" + - name: Lint + env: + PACKAGE_MANAGER: ${{ inputs.pnpm-version != '' && 'pnpm' || 'npm' }} + run: | + for script in lint format:check typecheck; do + "$PACKAGE_MANAGER" run --if-present "$script" + done + - name: Build & test + env: + PACKAGE_MANAGER: ${{ inputs.pnpm-version != '' && 'pnpm' || 'npm' }} + TEST_SCRIPT: ${{ inputs.test-script }} + run: | + "$PACKAGE_MANAGER" run --if-present build + "$PACKAGE_MANAGER" run --if-present "$TEST_SCRIPT" + - name: Run optional custom tests + if: ${{ inputs.custom-tests != '' }} run: | - ${{ inputs.test-command }} - - name: Upload the reports - if: always() && inputs.artifact-path != '' + ${{ inputs.custom-tests }} + - name: Bring back workflow parts + working-directory: . + run: mv "${RUNNER_TEMP}/workflow-parts" workflow-parts + - name: Analyze with Sonar + if: ${{ inputs.sonar-enabled }} + uses: ./workflow-parts/actions/sonar/scan + with: + coverage-paths: ${{ inputs.sonar-coverage-paths }} + cpd-exclusions: ${{ inputs.sonar-cpd-exclusions }} + # The workflow parts are back beside the repository, and not part of its code. + exclusions: workflow-parts/**,${{ inputs.sonar-exclusions }} + host-url: ${{ inputs.sonar-host-url }} + organization: ${{ inputs.sonar-organization }} + project-key: ${{ inputs.sonar-project-key }} + project-name: ${{ inputs.sonar-project-name }} + token: ${{ secrets.sonar-token }} + working-directory: ${{ inputs.working-directory }} + - name: Check license compliance with FOSSA + # A repository turns it off where FOSSA cannot be reached, a local run holding no API key. + if: ${{ inputs.fossa-enabled }} + uses: ./workflow-parts/actions/fossa/analyze + # https://status.fossa.com/ + continue-on-error: true + timeout-minutes: 3 + with: + api-key: "${{ secrets.fossa-api-key }}" + run-tests: ${{ inputs.fossa-test && github.event_name == 'pull_request' }} + test-diff-revision: ${{ github.event.pull_request.base.sha }} + report-file: report/fossa.html + working-directory: ${{ inputs.working-directory }} + - name: Generate SBOM with Syft + uses: ./workflow-parts/actions/syft/generate-sbom + continue-on-error: true + with: + target-path: ${{ inputs.working-directory }} + - name: Archive test results + if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: ${{ inputs.artifact-name }} - path: ${{ inputs.artifact-path }} - retention-days: 7 - if-no-files-found: ignore + name: node-test-results + # The usual folders of the coverage and of Playwright, and report for anything else, a custom test's server log for instance. + path: | + ${{ inputs.working-directory }}/report/ + ${{ inputs.working-directory }}/coverage/ + ${{ inputs.working-directory }}/playwright-report/ + ${{ inputs.working-directory }}/test-results/ diff --git a/.istarci.yml b/.istarci.yml index 13b2792..da14326 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -9,10 +9,11 @@ runner: test-playwright-install: ghcr.io/devpro/debian-node-playwright:latest test-node-quality-npm: ghcr.io/devpro/debian-node-playwright:latest test-node-quality-pnpm: ghcr.io/devpro/debian-node:latest + test-node-quality-sonar: ghcr.io/devpro/debian-node-playwright:latest test-npm-publication: ghcr.io/devpro/debian-node:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] - # The Sonar path of the .NET quality workflow runs against a SonarQube of this machine, which GitHub does not have. + # The Sonar path of the .NET and Node.js quality workflows runs against a SonarQube of this machine, which GitHub does not have. sonarqube: true workflow: diff --git a/README.md b/README.md index ecec527..aba4d22 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Markup (Markdown, YAML): Node.js: -- [Lint, build and test the code](.github/workflows/reusable-node-quality.yml) +- [Lint, build, test and analyze the code](.github/workflows/reusable-node-quality.yml) - [Publish a package to npmjs.org or GitHub Packages](.github/workflows/reusable-npm-publication.yml) Terraform: @@ -91,6 +91,10 @@ Node.js: - [Publish a package unless its version already is](actions/npm/publish/action.yml) - [Install the Playwright browsers](actions/playwright/install/action.yml) +Sonar: + +- [Analyze the code of a project other than .NET](actions/sonar/scan/action.yml) + SBOM: - [Generate a SBOM with Syft](actions/syft/generate-sbom/action.yml) diff --git a/actions/mongodb/start/action.yml b/actions/mongodb/start/action.yml index b037eaa..614ffdb 100644 --- a/actions/mongodb/start/action.yml +++ b/actions/mongodb/start/action.yml @@ -1,6 +1,6 @@ name: Start MongoDB description: | - Starts a local MongoDB server on 127.0.0.1:27017, with mongosh on the path, + Starts a local MongoDB server on 127.0.0.1, with mongosh on the path, from the official archives with their SHA256 checksums verified rather than the apt packages, so neither root nor sudo is needed, on a hosted runner or in a container. Linux x64 or arm64 runners on Ubuntu 24.04 only (for example ubuntu-latest or ubuntu-24.04-arm). @@ -22,6 +22,14 @@ inputs: description: SHA256 of the mongosh archive for arm64, given with any other mongosh-version required: false default: "a124ec6680c70ceabc61696bbd110f0da05d7c52524ed402ae9511b5aec7fe32" + port: + description: Port the server listens on + required: false + default: "27017" + replica-set: + description: Name of a single-node replica set the server starts as, for an application writing in transactions, which a standalone server refuses, standalone when empty + required: false + default: "" runs: using: "composite" @@ -33,6 +41,8 @@ runs: MONGOSH_VERSION: ${{ inputs.mongosh-version }} MONGOSH_ARCHIVE: mongosh-${{ inputs.mongosh-version }}-linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}.tgz MONGOSH_SHA256: ${{ runner.arch == 'ARM64' && inputs.mongosh-sha256-arm64 || inputs.mongosh-sha256 }} + MONGODB_PORT: ${{ inputs.port }} + REPLICA_SET: ${{ inputs.replica-set }} run: | # Downloaded into a directory of its own, since the repository may hold a file or a directory named like an archive. cd "$(mktemp -d)" @@ -55,11 +65,27 @@ runs: rm -f "${MONGODB_ARCHIVE}" "${MONGODB_ARCHIVE}.sha256" "${MONGOSH_ARCHIVE}" # Forked rather than started by systemd, which a container has no instance of. - mongod --dbpath "${RUNNER_TEMP}/mongodb/data" --bind_ip 127.0.0.1 --fork --logpath "${RUNNER_TEMP}/mongodb/mongod.log" - timeout 30 bash -c " - until mongosh --host localhost:27017 --quiet --eval 'db.runCommand({ ping: 1 })' >/dev/null 2>&1; do - echo 'MongoDB not ready yet, retrying in 1 second...' - sleep 1 - done - " - mongod --version | head -1 + log="${RUNNER_TEMP}/mongodb/mongod.log" + args=(--dbpath "${RUNNER_TEMP}/mongodb/data" --bind_ip 127.0.0.1 --port "$MONGODB_PORT" --fork --logpath "$log") + check='db.runCommand({ ping: 1 })' + if [[ -n "$REPLICA_SET" ]]; then + args+=(--replSet "$REPLICA_SET") + # Ready once the node is primary, the set being initiated by the first check the server answers. + check="try { rs.status() } catch (e) { rs.initiate({ _id: '${REPLICA_SET}', members: [{ _id: 0, host: '127.0.0.1:${MONGODB_PORT}' }] }) } quit(db.hello().isWritablePrimary ? 0 : 1)" + fi + # The log is the only place saying why a server did not start, and it is gone with the runner. + if ! mongod "${args[@]}"; then + cat "$log" + exit 1 + fi + for _ in $(seq 1 30); do + if mongosh --host "127.0.0.1:${MONGODB_PORT}" --quiet --eval "$check" >/dev/null 2>&1; then + mongod --version | head -1 + exit 0 + fi + echo "MongoDB not ready yet, retrying in 1 second..." + sleep 1 + done + echo "::error::MongoDB was not ready within 30 seconds" + cat "$log" + exit 1 diff --git a/actions/sonar/scan/action.yml b/actions/sonar/scan/action.yml new file mode 100644 index 0000000..d1dfd47 --- /dev/null +++ b/actions/sonar/scan/action.yml @@ -0,0 +1,112 @@ +name: Analyze with Sonar +description: | + Installs the Sonar scanner CLI from the official archive, its SHA256 checksum verified, with the Java runtime it bundles, + and analyzes the code of a project built by another tool than .NET, JavaScript and TypeScript first, with the coverage of its lcov reports + (replacement for SonarSource/sonarqube-scan-action, to reduce third-party GitHub Action supply-chain risk). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + scanner-version: + description: Version of the Sonar scanner CLI (check latest from https://github.com/SonarSource/sonar-scanner-cli/releases) + required: false + default: "8.1.0.6389" + scanner-sha256: + description: SHA256 of the scanner archive for x64, given with any other scanner-version + required: false + default: "bb8f709f9cb73352f8d1260a3b3c506c0f41146754bc630762c126d795499d0b" + scanner-sha256-arm64: + description: SHA256 of the scanner archive for arm64, given with any other scanner-version + required: false + default: "5e1c9328f4e261838de778c9e586ee608cca45ff7f0538108642219214628ba5" + coverage-paths: + description: lcov reports of the coverage, relative to the working directory, separated by commas + required: false + default: "coverage/lcov.info" + cpd-exclusions: + description: Files or directories that should be excluded from Sonar code duplication analysis + required: false + default: "" + exclusions: + description: Files or directories that should be excluded from Sonar analysis + required: false + default: "**/node_modules/**" + host-url: + description: Sonar host URL + required: true + organization: + description: Sonar organization, which SonarCloud needs and a SonarQube server has no notion of + required: false + default: "" + project-key: + description: Sonar project key + required: true + project-name: + description: Sonar project name + required: false + default: "" + token: + description: Sonar token for login + required: true + working-directory: + description: Directory of the project to analyze, relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." + +runs: + using: "composite" + steps: + - name: Install Sonar scanner + shell: bash + env: + SCANNER_VERSION: ${{ inputs.scanner-version }} + SCANNER_PLATFORM: linux-${{ runner.arch == 'ARM64' && 'aarch64' || 'x64' }} + SCANNER_SHA256: ${{ runner.arch == 'ARM64' && inputs.scanner-sha256-arm64 || inputs.scanner-sha256 }} + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the archive. + cd "$(mktemp -d)" + archive="sonar-scanner-cli-${SCANNER_VERSION}-${SCANNER_PLATFORM}.zip" + curl -fsSL -O "https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/${archive}" + echo "${SCANNER_SHA256} ${archive}" | sha256sum --check --status + unzip -q "$archive" + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + rm -rf "${RUNNER_TEMP}/sonar-scanner" + mv "sonar-scanner-${SCANNER_VERSION}-${SCANNER_PLATFORM}" "${RUNNER_TEMP}/sonar-scanner" + rm -f "$archive" + echo "${RUNNER_TEMP}/sonar-scanner/bin" >> "$GITHUB_PATH" + # The analyzers Sonar downloads land in its user home. + - name: Cache Sonar packages + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + - name: Analyze the code + shell: bash + working-directory: ${{ inputs.working-directory }} + env: + COVERAGE_PATHS: ${{ inputs.coverage-paths }} + SONAR_CPD_EXCLUSIONS: ${{ inputs.cpd-exclusions }} + SONAR_EXCLUSIONS: ${{ inputs.exclusions }} + SONAR_HOST_URL: ${{ inputs.host-url }} + SONAR_ORGANIZATION: ${{ inputs.organization }} + SONAR_PROJECT_KEY: ${{ inputs.project-key }} + SONAR_PROJECT_NAME: ${{ inputs.project-name }} + SONAR_TOKEN: ${{ inputs.token }} + run: | + # The token is read from SONAR_TOKEN, rather than given on the command line any process can list. + args=( + -Dsonar.host.url="$SONAR_HOST_URL" + -Dsonar.projectKey="$SONAR_PROJECT_KEY" + -Dsonar.exclusions="$SONAR_EXCLUSIONS" + -Dsonar.javascript.lcov.reportPaths="$COVERAGE_PATHS" + ) + if [ -n "$SONAR_CPD_EXCLUSIONS" ]; then + args+=(-Dsonar.cpd.exclusions="$SONAR_CPD_EXCLUSIONS") + fi + if [ -n "$SONAR_ORGANIZATION" ]; then + args+=(-Dsonar.organization="$SONAR_ORGANIZATION") + fi + if [ -n "$SONAR_PROJECT_NAME" ]; then + args+=(-Dsonar.projectName="$SONAR_PROJECT_NAME") + fi + sonar-scanner "${args[@]}" diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py index 0fff9b7..b8e6403 100755 --- a/test/check_workflow_parts.py +++ b/test/check_workflow_parts.py @@ -22,10 +22,10 @@ SHELL_INPUTS = { "inputs.build-command", "inputs.custom-commands", + "inputs.custom-tests", "inputs.dotnet-test-args", "inputs.publish-command", "inputs.terraform-var-flags", - "inputs.test-command", } # Parts no CI job can exercise, each with the reason, which the coverage check takes as known. diff --git a/test/fixtures/node/package-lock.json b/test/fixtures/node/package-lock.json new file mode 100644 index 0000000..51adacd --- /dev/null +++ b/test/fixtures/node/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "node-fixture", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "node-fixture", + "dependencies": { + "playwright": "1.63.0" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/test/fixtures/node/package.json b/test/fixtures/node/package.json new file mode 100644 index 0000000..1f0ef41 --- /dev/null +++ b/test/fixtures/node/package.json @@ -0,0 +1,15 @@ +{ + "name": "node-fixture", + "private": true, + "scripts": { + "lint": "node record.js lint", + "format:check": "node record.js format:check", + "typecheck": "node record.js typecheck", + "build": "node record.js build", + "test": "node record.js test", + "test:coverage": "node record.js test:coverage && mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info" + }, + "dependencies": { + "playwright": "1.63.0" + } +} diff --git a/test/fixtures/node/record.js b/test/fixtures/node/record.js new file mode 100644 index 0000000..513525c --- /dev/null +++ b/test/fixtures/node/record.js @@ -0,0 +1,10 @@ +// Writes the name of the script run into report/scripts.log, for the custom tests of the CI to tell which ran and in which order. +const fs = require("node:fs"); +const path = require("node:path"); + +// The scripts see the repository alone, without the workflow parts checked out beside it. +if (process.env.GITHUB_WORKSPACE && fs.existsSync(path.join(process.env.GITHUB_WORKSPACE, "workflow-parts"))) { + throw new Error("The workflow parts are beside the repository"); +} +fs.mkdirSync("report", { recursive: true }); +fs.appendFileSync("report/scripts.log", `${process.argv[2]}\n`); diff --git a/test/fixtures/node/record.test.js b/test/fixtures/node/record.test.js new file mode 100644 index 0000000..716a484 --- /dev/null +++ b/test/fixtures/node/record.test.js @@ -0,0 +1,6 @@ +const assert = require("node:assert"); +const test = require("node:test"); + +test("the fixture has a test, whose coverage Sonar reads", () => { + assert.strictEqual(1 + 1, 2); +}); From c741594d8a839528b0f87c955e2887ac852a3d25 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 21 Oct 2026 14:34:08 +0200 Subject: [PATCH 36/55] Leave the workflow parts out of the FOSSA analysis and of the SBOM of the quality workflows Both scan the working directory, where the workflow parts are checked out beside the code, so the SBOM listed the dependencies of their fixtures. FOSSA only skipped them since it skips folders named test, which a manifest elsewhere in the parts would not be. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 ++++++++ .github/workflows/reusable-dotnet-quality.yml | 3 +++ .github/workflows/reusable-node-quality.yml | 3 +++ actions/fossa/analyze/action.yml | 14 +++++++++++++- actions/syft/generate-sbom/action.yml | 15 ++++++++++++++- 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35afbe9..4686954 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,6 +430,13 @@ jobs: target-path: actions output-file: sbom-test.spdx.json upload-artifact: "false" + - name: Generate SBOM for a directory, one folder left out + uses: ./actions/syft/generate-sbom + with: + exclude-paths: node + target-path: test/fixtures + output-file: sbom-exclude-test.spdx.json + upload-artifact: "false" - name: Generate SBOM for a public image uses: ./actions/syft/generate-sbom with: @@ -442,6 +449,7 @@ jobs: test -s sbom-test.spdx.json grep -q '"spdxVersion"' sbom-test.spdx.json jq -e '.bomFormat == "CycloneDX" and (.components | length > 0)' sbom-image-test.cdx.json + jq -e '[.packages[].name] | index("playwright-fixture") and (index("node-fixture") | not)' sbom-exclude-test.spdx.json test-trivy-scan: name: "Test / Trivy scan action" diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index d951bfa..0f6c101 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -198,6 +198,8 @@ jobs: timeout-minutes: 3 with: api-key: "${{ secrets.fossa-api-key }}" + # The workflow parts are checked out beside the code, and are no part of it. + exclude-paths: workflow-parts run-tests: ${{ inputs.fossa-test && github.event_name == 'pull_request' }} test-diff-revision: ${{ github.event.pull_request.base.sha }} report-file: report/fossa.html @@ -206,6 +208,7 @@ jobs: uses: ./workflow-parts/actions/syft/generate-sbom continue-on-error: true with: + exclude-paths: workflow-parts target-path: ${{ inputs.working-directory }} - name: Archive test results if: always() diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index fed83a6..7152c1a 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -283,6 +283,8 @@ jobs: timeout-minutes: 3 with: api-key: "${{ secrets.fossa-api-key }}" + # The workflow parts are checked out beside the code, and are no part of it. + exclude-paths: workflow-parts run-tests: ${{ inputs.fossa-test && github.event_name == 'pull_request' }} test-diff-revision: ${{ github.event.pull_request.base.sha }} report-file: report/fossa.html @@ -291,6 +293,7 @@ jobs: uses: ./workflow-parts/actions/syft/generate-sbom continue-on-error: true with: + exclude-paths: workflow-parts target-path: ${{ inputs.working-directory }} - name: Archive test results if: always() diff --git a/actions/fossa/analyze/action.yml b/actions/fossa/analyze/action.yml index 856e01c..4c97057 100644 --- a/actions/fossa/analyze/action.yml +++ b/actions/fossa/analyze/action.yml @@ -13,6 +13,10 @@ inputs: api-key: description: FOSSA API key required: true + exclude-paths: + description: Paths left out of the analysis, relative to working-directory, one per line + required: false + default: "" run-tests: description: Test the analyzed dependencies against the FOSSA policy required: false @@ -70,7 +74,15 @@ runs: working-directory: ${{ inputs.working-directory }} env: FOSSA_API_KEY: ${{ inputs.api-key }} - run: fossa analyze + EXCLUDE_PATHS: ${{ inputs.exclude-paths }} + run: | + args=() + while IFS= read -r path; do + if [ -n "$path" ]; then + args+=(--exclude-path "$path") + fi + done <<< "$EXCLUDE_PATHS" + fossa analyze "${args[@]}" - name: Test dependencies against the policy if: ${{ inputs.run-tests == 'true' }} diff --git a/actions/syft/generate-sbom/action.yml b/actions/syft/generate-sbom/action.yml index 402be86..a90d70a 100644 --- a/actions/syft/generate-sbom/action.yml +++ b/actions/syft/generate-sbom/action.yml @@ -18,6 +18,10 @@ inputs: description: Source directory to scan, used only when target-image is not set required: false default: "." + exclude-paths: + description: Paths left out of a source directory's SBOM, relative to target-path, one per line + required: false + default: "" output-format: description: SBOM format (see https://github.com/anchore/syft#supported-output-formats) required: false @@ -74,6 +78,7 @@ runs: env: TARGET_IMAGE: ${{ inputs.target-image }} TARGET_PATH: ${{ inputs.target-path }} + EXCLUDE_PATHS: ${{ inputs.exclude-paths }} OUTPUT_FORMAT: ${{ inputs.output-format }} OUTPUT_FILE: ${{ inputs.output-file }} run: | @@ -83,8 +88,16 @@ runs: SOURCE="dir:${TARGET_PATH}" fi + # Syft matches the exclusions of a directory against paths starting with ./ + args=() + while IFS= read -r path; do + if [ -n "$path" ]; then + args+=(--exclude "./${path#./}") + fi + done <<< "$EXCLUDE_PATHS" + echo "Scanning ${SOURCE} with Syft..." - syft "$SOURCE" -o "${OUTPUT_FORMAT}=${OUTPUT_FILE}" + syft "$SOURCE" "${args[@]}" -o "${OUTPUT_FORMAT}=${OUTPUT_FILE}" - name: Upload SBOM as artifact if: ${{ inputs.upload-artifact == 'true' }} uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 From 943c8ad7e62e471283fcbc60823cf9128af2c520 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 21 Oct 2026 14:51:18 +0200 Subject: [PATCH 37/55] Build an npm package with the build script of the repository by default, as the Node.js quality workflow does A package whose build is its build script no longer needs a build command, and a repository whose root holds no package.json skips the build rather than failing on it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 1 - .github/workflows/reusable-npm-publication.yml | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4686954..eae1ab2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -750,7 +750,6 @@ jobs: name: "Test / npm publication reusable workflow, without publishing" uses: ./.github/workflows/reusable-npm-publication.yml with: - build-command: test -f test/fixtures/npm/unpublished/index.js # A dry run goes through every step of a publication but the upload, which needs no credentials. publish-command: npm publish --dry-run working-directory: test/fixtures/npm/unpublished diff --git a/.github/workflows/reusable-npm-publication.yml b/.github/workflows/reusable-npm-publication.yml index fa9cbbd..c9683f7 100644 --- a/.github/workflows/reusable-npm-publication.yml +++ b/.github/workflows/reusable-npm-publication.yml @@ -5,10 +5,11 @@ on: workflow_call: inputs: build-command: - description: Commands building and checking the package before it is published, run from the root of the repository, none when empty + description: Commands building and checking the package before it is published, run from the root of the repository, the build script of its package.json by default, none when empty type: string required: false - default: "" + # A repository whose root holds no package.json has nothing npm could build there. + default: "if [ -f package.json ]; then npm run --if-present build; fi" job-name: description: Job name type: string From 9acdc86413050ad11ee07e20346691ca69c0f222 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 21 Oct 2026 22:27:12 +0200 Subject: [PATCH 38/55] Make the container scan the only judge of a built image: a severity to fail on, an ignore file with expiry, and a registry login for a private base The thresholds were declared and never read, the scan failing on any finding whatever a caller passed, and a count of tolerated vulnerabilities would let through whichever came next. An ignore file names each vulnerability left out, with the reason and the day it counts again, as IstarCI does. NeuVector, commented out since 2026-02-22, and the switch turning Trivy off go, which left a job that only built. A login before the builds lets an image build on a base its owner keeps private, and is skipped where the run holds no token. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 39 ++++++++++- .github/workflows/reusable-container-scan.yml | 70 +++++++++++-------- actions/trivy/scan/action.yml | 14 ++++ test/check_workflow_parts.py | 4 +- test/fixtures/trivy/ignore-current.yaml | 5 ++ test/fixtures/trivy/ignore-expired.yaml | 5 ++ 6 files changed, 102 insertions(+), 35 deletions(-) create mode 100644 test/fixtures/trivy/ignore-current.yaml create mode 100644 test/fixtures/trivy/ignore-expired.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eae1ab2..5573ad8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -483,12 +483,42 @@ jobs: target: test/fixtures/container/base severity: HIGH,CRITICAL exit-code: "1" - - name: Verify SARIF report was generated and the finding failed the scan + - name: Scan a directory with a finding the ignore file leaves out + uses: ./actions/trivy/scan + with: + scan-type: config + target: test/fixtures/container/base + severity: HIGH,CRITICAL + exit-code: "1" + ignore-file: test/fixtures/trivy/ignore-current.yaml + - name: Scan a directory with a finding whose ignore entry expired, failing on it + id: trivy-expired + continue-on-error: true + uses: ./actions/trivy/scan + with: + scan-type: config + target: test/fixtures/container/base + severity: HIGH,CRITICAL + exit-code: "1" + ignore-file: test/fixtures/trivy/ignore-expired.yaml + - name: Scan with an ignore file that does not exist, failing on it + id: trivy-missing-ignore-file + continue-on-error: true + uses: ./actions/trivy/scan + with: + scan-type: config + target: test/fixtures/terraform + ignore-file: test/fixtures/trivy/missing.yaml + - name: Verify SARIF report was generated and the findings failed the scans env: + EXPIRED_OUTCOME: ${{ steps.trivy-expired.outcome }} FINDING_OUTCOME: ${{ steps.trivy-finding.outcome }} + MISSING_IGNORE_FILE_OUTCOME: ${{ steps.trivy-missing-ignore-file.outcome }} run: | jq -e '.version == "2.1.0"' trivy-test.sarif [ "$FINDING_OUTCOME" = failure ] + [ "$EXPIRED_OUTCOME" = failure ] + [ "$MISSING_IGNORE_FILE_OUTCOME" = failure ] test-terraform-quality: name: "Test / Terraform quality reusable workflow" @@ -550,10 +580,17 @@ jobs: uses: ./.github/workflows/reusable-container-scan.yml with: base-image-definition: test/fixtures/container/base/Dockerfile + # The login is skipped in a local run, which holds no token. + container-registry: ghcr.io image-definition: test/fixtures/container/derived/Dockerfile image-name: scan-test image-path: local image-tag: ${{ github.run_id }} + severity: CRITICAL,HIGH,MEDIUM + trivy-ignore-file: test/fixtures/trivy/ignore-current.yaml + secrets: + container-registry-username: ${{ github.actor }} + container-registry-password: ${{ secrets.GITHUB_TOKEN }} test-git-check-changes: name: "Test / Git check-changes action" diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index 35627cf..0d7f895 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -8,6 +8,11 @@ on: type: string required: false default: BASE_IMAGE + container-registry: + description: Registry to log in to before the builds, for a base image it keeps private, ghcr.io for instance, none when empty + type: string + required: false + default: "" base-image-definition: description: Path to the definition of the image this one is built on, built first so that the scan reads what this commit produces rather than a published base type: string @@ -39,31 +44,21 @@ on: type: number required: false default: 30 - max-high-cves: - description: Maximum number of high CVEs authorized - type: number - required: false - default: 0 - max-medium-cves: - description: Maximum number of medium CVEs authorized - type: number - required: false - default: 0 - neuvector-enabled: - description: "Use NeuVector to scan the image?" - type: boolean - required: false - default: false operating-system: description: Operating system executing the runner type: string required: false default: ubuntu-latest - trivy-enabled: - description: "Use Trivy to scan the image?" - type: boolean + severity: + description: Severities of a vulnerability with a fix that fail the scan, "CRITICAL,HIGH,MEDIUM" to hold an image to MEDIUM as well + type: string required: false - default: true + default: CRITICAL,HIGH + trivy-ignore-file: + description: Trivy ignore file, relative to the root of the repository, naming the vulnerabilities left out, each with the reason and the day it counts again (expired_at), read when it exists + type: string + required: false + default: .trivyignore.yaml workflow-parts-version: description: "GitHub workflow parts version (branch/tag/SHA). Should match the ref/tag/SHA the caller pinned for this reusable workflow itself. Ignored when the caller is this repository, whose checkout already holds the actions under test." type: string @@ -74,6 +69,13 @@ on: type: string required: false default: "." + secrets: + container-registry-username: + description: User name of the container registry + required: false + container-registry-password: + description: Password or token of the container registry, the login being skipped when empty, as in a local run + required: false jobs: container-scan: @@ -93,6 +95,20 @@ jobs: # The caller being this repository, the actions under test are those of the commit under test. ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts + # A base image kept private is pulled with the credentials of the run. + - name: Login to container registry + if: ${{ inputs.container-registry != '' }} + env: + REGISTRY: ${{ inputs.container-registry }} + REGISTRY_USERNAME: ${{ secrets.container-registry-username }} + REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} + run: | + if [ -z "$REGISTRY_PASSWORD" ]; then + echo "::notice::No password for ${REGISTRY}, so its images are pulled with the credentials the Docker client already holds" + exit 0 + fi + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin + shell: bash # A scan judges what a clean build produces today. # A cached `apt-get upgrade` layer or a stale base image keeps an already fixed package version, on a runner whose Docker daemon outlives the job. - name: Build base container image @@ -116,18 +132,8 @@ jobs: BASE_ARGUMENT: ${{ inputs.base-image-argument }} BASE_DEFINITION: ${{ inputs.base-image-definition }} IMAGE_DEFINITION: ${{ inputs.image-definition }} - # deactivated 2026-02-22: "Build container for action use... process "/bin/sh -c zypper in -y jq docker && zypper clean" did not complete successfully: exit code: 8" - # - name: Scan container image with NeuVector - # if: ${{ inputs.neuvector-enabled }} - # uses: neuvector/scan-action@196964fb269cfcd1fe139dd27c8440a8396bd8ab # 1.0.0 - # with: - # image-repository: ${{ inputs.image-path }}/${{ inputs.image-name }} - # image-tag: ${{ inputs.image-tag }} - # min-high-cves-to-fail: '${{ inputs.max-high-cves }}' - # min-medium-cves-to-fail: '${{ inputs.max-medium-cves }}' - # nv-scanner-image: neuvector/scanner:5 + # The only judge of the image a repository builds: any vulnerability of those severities with a fix fails the job, unless the ignore file names it until a day. - name: Scan container image with Trivy - if: ${{ inputs.trivy-enabled }} uses: ./workflow-parts/actions/trivy/scan with: scan-type: image @@ -135,8 +141,10 @@ jobs: format: table exit-code: "1" ignore-unfixed: "true" + # The default file is optional, and any other one named must exist. + ignore-file: ${{ (inputs.trivy-ignore-file != '.trivyignore.yaml' || hashFiles('.trivyignore.yaml') != '') && inputs.trivy-ignore-file || '' }} pkg-types: os,library - severity: CRITICAL,HIGH + severity: ${{ inputs.severity }} env: GITHUB_TOKEN: ${{ github.token }} IMAGE_REF: ${{ inputs.image-path }}/${{ inputs.image-name }}:${{ inputs.image-tag }} diff --git a/actions/trivy/scan/action.yml b/actions/trivy/scan/action.yml index 805e61c..5d7e3b2 100644 --- a/actions/trivy/scan/action.yml +++ b/actions/trivy/scan/action.yml @@ -41,6 +41,10 @@ inputs: description: Comma-separated package types to scan, os and/or library (scan-type "image" only) required: false default: "os,library" + ignore-file: + description: Trivy ignore file (.trivyignore.yaml) naming the findings left out, each with the reason and the day it counts again (expired_at), none when empty + required: false + default: "" runs: using: "composite" @@ -87,9 +91,19 @@ runs: EXIT_CODE: ${{ inputs.exit-code }} IGNORE_UNFIXED: ${{ inputs.ignore-unfixed }} PKG_TYPES: ${{ inputs.pkg-types }} + IGNORE_FILE: ${{ inputs.ignore-file }} run: | args=("$SCAN_TYPE" --format "$FORMAT" --severity "$SEVERITY" --exit-code "$EXIT_CODE") + # Trivy goes on without an ignore file it cannot find, which would hide a path given wrong. + if [ -n "$IGNORE_FILE" ]; then + if [ ! -f "$IGNORE_FILE" ]; then + echo "ERROR: Ignore file ${IGNORE_FILE} not found" >&2 + exit 1 + fi + args+=(--ignorefile "$IGNORE_FILE") + fi + if [ -n "$OUTPUT_FILE" ]; then args+=(--output "$OUTPUT_FILE") fi diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py index b8e6403..758bfdf 100755 --- a/test/check_workflow_parts.py +++ b/test/check_workflow_parts.py @@ -37,9 +37,7 @@ } # Inputs nothing reads, kept since a caller passing an input a reusable workflow does not declare fails. -RESERVED_INPUTS = { - ".github/workflows/reusable-container-scan.yml": {"max-high-cves", "max-medium-cves", "neuvector-enabled"}, -} +RESERVED_INPUTS = {} TEXT_SUFFIXES = {".cs", ".csproj", ".hcl", ".json", ".md", ".py", ".sh", ".tf", ".yaml", ".yml", ""} DASHES = (chr(0x2013), chr(0x2014)) diff --git a/test/fixtures/trivy/ignore-current.yaml b/test/fixtures/trivy/ignore-current.yaml new file mode 100644 index 0000000..c06b00c --- /dev/null +++ b/test/fixtures/trivy/ignore-current.yaml @@ -0,0 +1,5 @@ +# Leaves out the root user of the base container fixture until a day that has not come. +misconfigurations: + - id: DS-0002 + statement: The base container fixture runs as root on purpose + expired_at: 2099-12-31 diff --git a/test/fixtures/trivy/ignore-expired.yaml b/test/fixtures/trivy/ignore-expired.yaml new file mode 100644 index 0000000..260f642 --- /dev/null +++ b/test/fixtures/trivy/ignore-expired.yaml @@ -0,0 +1,5 @@ +# Leaves out the root user of the base container fixture until a day that has passed, so it counts again. +misconfigurations: + - id: DS-0002 + statement: The base container fixture runs as root on purpose + expired_at: 2020-01-01 From c95c8feaec09957b8d8de80d88d1f846cc4e2c9b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 21 Oct 2026 22:31:43 +0200 Subject: [PATCH 39/55] Remove the registry login of the container scan, a base image being public Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 ---- .github/workflows/reusable-container-scan.yml | 26 ------------------- 2 files changed, 31 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5573ad8..e905237 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -580,17 +580,12 @@ jobs: uses: ./.github/workflows/reusable-container-scan.yml with: base-image-definition: test/fixtures/container/base/Dockerfile - # The login is skipped in a local run, which holds no token. - container-registry: ghcr.io image-definition: test/fixtures/container/derived/Dockerfile image-name: scan-test image-path: local image-tag: ${{ github.run_id }} severity: CRITICAL,HIGH,MEDIUM trivy-ignore-file: test/fixtures/trivy/ignore-current.yaml - secrets: - container-registry-username: ${{ github.actor }} - container-registry-password: ${{ secrets.GITHUB_TOKEN }} test-git-check-changes: name: "Test / Git check-changes action" diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index 0d7f895..69a0998 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -8,11 +8,6 @@ on: type: string required: false default: BASE_IMAGE - container-registry: - description: Registry to log in to before the builds, for a base image it keeps private, ghcr.io for instance, none when empty - type: string - required: false - default: "" base-image-definition: description: Path to the definition of the image this one is built on, built first so that the scan reads what this commit produces rather than a published base type: string @@ -69,13 +64,6 @@ on: type: string required: false default: "." - secrets: - container-registry-username: - description: User name of the container registry - required: false - container-registry-password: - description: Password or token of the container registry, the login being skipped when empty, as in a local run - required: false jobs: container-scan: @@ -95,20 +83,6 @@ jobs: # The caller being this repository, the actions under test are those of the commit under test. ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts - # A base image kept private is pulled with the credentials of the run. - - name: Login to container registry - if: ${{ inputs.container-registry != '' }} - env: - REGISTRY: ${{ inputs.container-registry }} - REGISTRY_USERNAME: ${{ secrets.container-registry-username }} - REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} - run: | - if [ -z "$REGISTRY_PASSWORD" ]; then - echo "::notice::No password for ${REGISTRY}, so its images are pulled with the credentials the Docker client already holds" - exit 0 - fi - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin - shell: bash # A scan judges what a clean build produces today. # A cached `apt-get upgrade` layer or a stale base image keeps an already fixed package version, on a runner whose Docker daemon outlives the job. - name: Build base container image From 1e98f3c5f312daa3661c356f1746f034341a8602 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 21 Oct 2026 22:36:18 +0200 Subject: [PATCH 40/55] Build images with the BuildKit of the Docker daemon, rather than a builder running the BuildKit image Every BuildKit image, the latest release included, carries Go vulnerabilities with a fix, and runs with privileges in each job building an image. The builder of the daemon pushes no image by digest alone and builds one platform at a time, so each platform of several is pushed under a tag of its own, then put together under the tag. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 27 +++++--- .../reusable-container-publication.yml | 18 +++--- README.md | 2 +- actions/container/build-push/action.yml | 63 +++++++++++-------- 4 files changed, 68 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e905237..b7fee62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -274,16 +274,22 @@ jobs: with: image-definition: test/fixtures/container/base/Dockerfile image-repository: localhost:5000/emulated-test + image-tag: ${{ github.run_id }} platforms: linux/amd64,linux/arm64 - - name: Verify the manifest list holds both platforms + - name: Verify the tag names a manifest list holding both platforms, each also under a tag of its own env: DIGEST: ${{ steps.emulated.outputs.digest }} run: | - platforms=$(docker buildx imagetools inspect "localhost:5000/emulated-test@${DIGEST}" --raw | jq -r '[.manifests[].platform | select(.os == "linux") | .architecture] | sort | join(",")') + image="localhost:5000/emulated-test:${GITHUB_RUN_ID}" + [ "$(docker buildx imagetools inspect "$image" --format '{{json .Manifest}}' | jq -r .digest)" = "$DIGEST" ] + platforms=$(docker buildx imagetools inspect "$image" --raw | jq -r '[.manifests[].platform | select(.os == "linux") | .architecture] | sort | join(",")') [ "$platforms" = amd64,arm64 ] + for platform in linux-amd64 linux-arm64; do + docker buildx imagetools inspect "${image}-${platform}" >/dev/null + done test-container-build-push: - name: "Test / Container build-push action, under a tag and by digest" + name: "Test / Container build-push action, a base and an image built on it" runs-on: ubuntu-latest timeout-minutes: 20 services: @@ -302,21 +308,25 @@ jobs: image-repository: localhost:5000/build-test-base image-tag: ${{ github.run_id }} # The derived image fails to build unless the build argument names the base pushed above. - - name: Build and push the derived image by digest + - name: Build and push the derived image id: derived uses: ./actions/container/build-push with: image-definition: test/fixtures/container/derived/Dockerfile image-repository: localhost:5000/build-test-derived + image-tag: ${{ github.run_id }} build-arguments: | BASE_IMAGE=localhost:5000/build-test-base:${{ github.run_id }} - - name: Verify both images are in the registry + - name: Verify both images are in the registry under their digest, and not left in the daemon env: BASE_DIGEST: ${{ steps.base.outputs.digest }} DERIVED_DIGEST: ${{ steps.derived.outputs.digest }} run: | - [ "$(docker buildx imagetools inspect "localhost:5000/build-test-base:${GITHUB_RUN_ID}" --format '{{json .Manifest}}' | jq -r .digest)" = "$BASE_DIGEST" ] - docker buildx imagetools inspect "localhost:5000/build-test-derived@${DERIVED_DIGEST}" >/dev/null + for image in "build-test-base:${GITHUB_RUN_ID}=${BASE_DIGEST}" "build-test-derived:${GITHUB_RUN_ID}=${DERIVED_DIGEST}"; do + ref="localhost:5000/${image%%=*}" + [ "$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' | jq -r .digest)" = "${image#*=}" ] + ! docker image inspect "$ref" >/dev/null 2>&1 + done test-container-build-push-arm64: name: "Test / Container build-push action on an arm64 runner" @@ -330,12 +340,13 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Build and push the base image natively by digest + - name: Build and push the base image natively id: native uses: ./actions/container/build-push with: image-definition: test/fixtures/container/base/Dockerfile image-repository: localhost:5000/native-test + image-tag: ${{ github.run_id }} platforms: linux/arm64 - name: Verify the image is for arm64 env: diff --git a/.github/workflows/reusable-container-publication.yml b/.github/workflows/reusable-container-publication.yml index 40f48aa..e88d581 100644 --- a/.github/workflows/reusable-container-publication.yml +++ b/.github/workflows/reusable-container-publication.yml @@ -94,8 +94,6 @@ jobs: if: ${{ inputs.arm64-runner == '' }} runs-on: ${{ inputs.operating-system }} timeout-minutes: ${{ inputs.timeout-minutes }} - outputs: - digest: ${{ steps.build.outputs.digest }} steps: - name: Set secret variables shell: bash @@ -131,12 +129,13 @@ jobs: uses: ./workflow-parts/actions/qemu/setup with: platforms: ${{ inputs.image-platform }} - - name: Build and push container image by digest - id: build + # The action puts the platforms together under the tag once each is pushed. + - name: Build and push container image uses: ./workflow-parts/actions/container/build-push with: image-definition: ${{ inputs.image-definition }} image-repository: ${{ env.IMAGE_REPOSITORY }} + image-tag: ${{ inputs.image-tag }} platforms: ${{ inputs.image-platform }} build-arguments: ${{ inputs.extra-build-arguments }} working-directory: ${{ inputs.working-directory }} @@ -177,12 +176,13 @@ jobs: REGISTRY_USERNAME: ${{ secrets.container-registry-username }} REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin - - name: Build and push container image by digest + - name: Build and push container image under the tag of its platform id: build uses: ./workflow-parts/actions/container/build-push with: image-definition: ${{ inputs.image-definition }} image-repository: ${{ env.IMAGE_REPOSITORY }} + image-tag: ${{ inputs.image-tag }}-linux-amd64 platforms: linux/amd64 build-arguments: ${{ inputs.extra-build-arguments }} working-directory: ${{ inputs.working-directory }} @@ -223,17 +223,18 @@ jobs: REGISTRY_USERNAME: ${{ secrets.container-registry-username }} REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin - - name: Build and push container image by digest + - name: Build and push container image under the tag of its platform id: build uses: ./workflow-parts/actions/container/build-push with: image-definition: ${{ inputs.image-definition }} image-repository: ${{ env.IMAGE_REPOSITORY }} + image-tag: ${{ inputs.image-tag }}-linux-arm64 platforms: linux/arm64 build-arguments: ${{ inputs.extra-build-arguments }} working-directory: ${{ inputs.working-directory }} - # The platforms are built apart, and only put under the tag here, so that the image a tag names is complete. + # The platforms built on runners of their own are only put under the tag here, so that the image a tag names is complete. publication: name: ${{ inputs.job-name }} needs: [build-emulated, build-amd64, build-arm64] @@ -256,8 +257,9 @@ jobs: REGISTRY_PASSWORD: ${{ secrets.container-registry-password }} run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin - name: Tag the image of every platform + if: ${{ inputs.arm64-runner != '' }} env: - DIGESTS: ${{ needs.build-emulated.outputs.digest }} ${{ needs.build-amd64.outputs.digest }} ${{ needs.build-arm64.outputs.digest }} + DIGESTS: ${{ needs.build-amd64.outputs.digest }} ${{ needs.build-arm64.outputs.digest }} run: | sources=() for digest in $DIGESTS; do diff --git a/README.md b/README.md index aba4d22..fdc1fca 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Terraform: Containers: -- [Build and push an image, under a tag or by digest](actions/container/build-push/action.yml) +- [Build and push an image for one or more platforms](actions/container/build-push/action.yml) - [Sign an image with Cosign](actions/cosign/sign/action.yml) - [Setup QEMU to build images for other platforms](actions/qemu/setup/action.yml) - [Scan an image or a directory with Trivy](actions/trivy/scan/action.yml) diff --git a/actions/container/build-push/action.yml b/actions/container/build-push/action.yml index e7b0336..ce0772a 100644 --- a/actions/container/build-push/action.yml +++ b/actions/container/build-push/action.yml @@ -1,7 +1,7 @@ name: Build and push a container image description: | - Builds a container image with Docker Buildx for one or more platforms and pushes it, - under a tag, or by digest only for a job that merges the digests of several platforms afterwards. + Builds a container image with the BuildKit of the Docker daemon for one or more platforms and pushes it under a tag, + each platform of several being pushed under a tag of its own first, then put together under the tag. The registry login is left to the job, which holds the credentials. Linux runners only (for example ubuntu-latest or ubuntu-24.04-arm). @@ -13,9 +13,8 @@ inputs: description: Repository of the image, with its registry (for example docker.io/devprofr/app) required: true image-tag: - description: Tag to push, or empty to push by digest only - required: false - default: "" + description: Tag to push, the tag of each platform of several being this one followed by the platform (1.0.1-linux-arm64 for instance) + required: true platforms: description: Comma-separated platforms to build (a platform other than the runner's own needs the Setup QEMU action first) required: false @@ -38,7 +37,8 @@ runs: using: "composite" steps: # The Docker CLI of the runner does what the docker/* actions wrapped, with no third party action in between. - # The gha cache backend is reachable from JavaScript actions only, so the build runs without a cache. + # The builder of the daemon runs no BuildKit image, which a builder of its own would pull, and keeps no state of its own a later job would find. + # It pushes no image by digest alone and builds one platform at a time on the classic image store, so each platform gets a tag. - name: Build and push container image id: build shell: bash @@ -50,26 +50,39 @@ runs: PLATFORMS: ${{ inputs.platforms }} BUILD_ARGUMENTS: ${{ inputs.build-arguments }} run: | - metadata="$(mktemp)" - # A builder of its own, removed afterwards, since a Docker daemon shared with other jobs keeps what a job leaves on it. - # It shares the network of the runner, which is how it reaches a registry listening on localhost. - builder="build-push-${GITHUB_RUN_ID:-local}-$$" - docker buildx create --name "$builder" --driver docker-container --driver-opt network=host >/dev/null - trap 'docker buildx rm "$builder" >/dev/null 2>&1 || true' EXIT - args=(--builder "$builder" --file "$IMAGE_DEFINITION" --platform "$PLATFORMS" --metadata-file "$metadata") - if [ -n "$IMAGE_TAG" ]; then - args+=(--tag "${IMAGE_REPOSITORY}:${IMAGE_TAG}" --push) - else - args+=(--output "type=image,name=${IMAGE_REPOSITORY},push-by-digest=true,name-canonical=true,push=true") - fi + build_args=() while IFS= read -r argument; do - [ -n "$argument" ] && args+=(--build-arg "$argument") + [ -n "$argument" ] && build_args+=(--build-arg "$argument") done <<< "$BUILD_ARGUMENTS" - docker buildx build "${args[@]}" . - digest="$(jq -r '."containerimage.digest" // empty' "$metadata")" - if [[ "$digest" != sha256:* ]]; then - echo "ERROR: The build reported no digest for ${IMAGE_REPOSITORY}" >&2 - exit 1 + + # Builds and pushes one platform under a tag, and prints the digest of what was pushed. + build_push() { + local platform="$1" ref="$2" metadata digest + metadata="$(mktemp)" + docker buildx build --builder default --file "$IMAGE_DEFINITION" --platform "$platform" \ + --tag "$ref" --push --metadata-file "$metadata" "${build_args[@]}" . >&2 + digest="$(jq -r '."containerimage.digest" // empty' "$metadata")" + # The daemon keeps the image it built, which the registry now holds. + docker image rm "$ref" >/dev/null 2>&1 || true + if [[ "$digest" != sha256:* ]]; then + echo "ERROR: The build reported no digest for ${ref}" >&2 + return 1 + fi + echo "$digest" + } + + IFS=',' read -ra platform_list <<< "$PLATFORMS" + if [ "${#platform_list[@]}" -eq 1 ]; then + digest="$(build_push "${platform_list[0]}" "${IMAGE_REPOSITORY}:${IMAGE_TAG}")" + else + sources=() + for platform in "${platform_list[@]}"; do + platform_digest="$(build_push "$platform" "${IMAGE_REPOSITORY}:${IMAGE_TAG}-${platform//\//-}")" + sources+=("${IMAGE_REPOSITORY}@${platform_digest}") + done + # The tag names a complete image only once every platform is pushed. + docker buildx imagetools create --tag "${IMAGE_REPOSITORY}:${IMAGE_TAG}" "${sources[@]}" + digest="$(docker buildx imagetools inspect "${IMAGE_REPOSITORY}:${IMAGE_TAG}" --format '{{json .Manifest}}' | jq -r .digest)" fi - echo "Pushed ${IMAGE_REPOSITORY}@${digest}" + echo "Pushed ${IMAGE_REPOSITORY}:${IMAGE_TAG}@${digest}" echo "digest=$digest" >> "$GITHUB_OUTPUT" From ffa67379ecc04a3a2a1c94f48fb2801d4e6c98ab Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 00:39:28 +0200 Subject: [PATCH 41/55] Run the shell code and shell words of an input from a variable, so that no script holds an expression GitHub pastes an expression into the script text before the shell reads it; eval reads the same text from a variable, quotes included, and the check of the scripts no longer keeps a list of inputs it lets through. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 15 +++++++++++---- .github/workflows/reusable-dotnet-quality.yml | 6 ++++-- .github/workflows/reusable-node-quality.yml | 6 ++++-- .github/workflows/reusable-npm-publication.yml | 6 ++++-- .github/workflows/reusable-pages-deployment.yml | 6 ++++-- .../workflows/reusable-terraform-deployment.yml | 13 ++++++++++--- actions/dotnet/build-test-sonar/action.yml | 6 +++++- actions/dotnet/build-test/action.yml | 6 +++++- actions/npm/publish/action.yml | 9 +++++++-- test/check_workflow_parts.py | 13 ++----------- 10 files changed, 56 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7fee62..4dc7f1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -542,8 +542,13 @@ jobs: uses: ./.github/workflows/reusable-dotnet-quality.yml with: working-directory: test/fixtures/dotnet - # The fixture holds a test that fails, which only the filter keeps the job from running. - dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace Fixture.Tests.LeftOut* + # Each line runs as written, quotes included, or the commands fail. + custom-commands: | + words=("two words" three) + [ "${#words[@]}" = 2 ] + [ "$(dotnet --version | cut -d . -f 1)" -ge 10 ] + # The fixture holds a test that fails, which only the filter keeps the job from running, its quotes being read as shell quotes rather than passed on. + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace 'Fixture.Tests.LeftOut*' # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. test-dotnet-quality-sonar: @@ -552,7 +557,7 @@ jobs: uses: ./.github/workflows/reusable-dotnet-quality.yml with: working-directory: test/fixtures/dotnet - dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace Fixture.Tests.LeftOut* + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace 'Fixture.Tests.LeftOut*' sonar-enabled: true sonar-host-url: ${{ vars.SONAR_HOST_URL }} sonar-project-key: github-workflow-parts-fixture @@ -574,7 +579,7 @@ jobs: - name: Build & test uses: ./actions/dotnet/build-test with: - dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace Fixture.Tests.LeftOut* + dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace 'Fixture.Tests.LeftOut*' working-directory: test/fixtures/dotnet # The paths are those the .NET quality workflow uploads as its artifact. - name: Verify the results, the coverage and the reports @@ -748,6 +753,8 @@ jobs: with: custom-tests: | [ "$FIXTURE_VALUE" = extra ] + words=("two words" three) + [ "${#words[@]}" = 2 ] mongosh --quiet --eval 'db.hello().isWritablePrimary' "mongodb://127.0.0.1:27017/?replicaSet=rs0" | grep -qx true task --version terraform version diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 0f6c101..cf1ef6c 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -168,8 +168,10 @@ jobs: uses: ./workflow-parts/actions/mongodb/start - name: Run optional custom commands if: ${{ inputs.custom-commands != '' }} - run: | - ${{ inputs.custom-commands }} + env: + CUSTOM_COMMANDS: ${{ inputs.custom-commands }} + # Shell code by contract, run from a variable rather than pasted into the script by GitHub. + run: eval "$CUSTOM_COMMANDS" - name: Build & test if: ${{ ! inputs.sonar-enabled }} uses: ./workflow-parts/actions/dotnet/build-test diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index 7152c1a..bfb1558 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -255,8 +255,10 @@ jobs: "$PACKAGE_MANAGER" run --if-present "$TEST_SCRIPT" - name: Run optional custom tests if: ${{ inputs.custom-tests != '' }} - run: | - ${{ inputs.custom-tests }} + env: + CUSTOM_TESTS: ${{ inputs.custom-tests }} + # Shell code by contract, run from a variable rather than pasted into the script by GitHub. + run: eval "$CUSTOM_TESTS" - name: Bring back workflow parts working-directory: . run: mv "${RUNNER_TEMP}/workflow-parts" workflow-parts diff --git a/.github/workflows/reusable-npm-publication.yml b/.github/workflows/reusable-npm-publication.yml index c9683f7..ec00332 100644 --- a/.github/workflows/reusable-npm-publication.yml +++ b/.github/workflows/reusable-npm-publication.yml @@ -120,8 +120,10 @@ jobs: fi - name: Build the package if: inputs.build-command != '' - run: | - ${{ inputs.build-command }} + env: + BUILD_COMMAND: ${{ inputs.build-command }} + # Shell code by contract, run from a variable rather than pasted into the script by GitHub. + run: eval "$BUILD_COMMAND" # GitHub Packages takes the token of the run, and npmjs.org the short-lived OIDC token of trusted publishing, which npm asks for itself. - name: Publish the package id: publish diff --git a/.github/workflows/reusable-pages-deployment.yml b/.github/workflows/reusable-pages-deployment.yml index 68ce4f8..b2e4dc0 100644 --- a/.github/workflows/reusable-pages-deployment.yml +++ b/.github/workflows/reusable-pages-deployment.yml @@ -113,8 +113,10 @@ jobs: cache: ${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) != '' && 'npm' || '' }} cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json - name: Build the site - run: | - ${{ inputs.build-command }} + env: + BUILD_COMMAND: ${{ inputs.build-command }} + # Shell code by contract, run from a variable rather than pasted into the script by GitHub. + run: eval "$BUILD_COMMAND" - name: Verify the site was built env: SITE_PATH: ${{ inputs.site-path }} diff --git a/.github/workflows/reusable-terraform-deployment.yml b/.github/workflows/reusable-terraform-deployment.yml index e4fa9a5..727cc6e 100644 --- a/.github/workflows/reusable-terraform-deployment.yml +++ b/.github/workflows/reusable-terraform-deployment.yml @@ -129,8 +129,10 @@ jobs: fi - name: Run optional custom commands if: ${{ inputs.custom-commands != '' }} - run: | - ${{ inputs.custom-commands }} + env: + CUSTOM_COMMANDS: ${{ inputs.custom-commands }} + # Shell code by contract, run from a variable rather than pasted into the script by GitHub. + run: eval "$CUSTOM_COMMANDS" - name: Cache Terraform plugins uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -144,8 +146,13 @@ jobs: - name: Terraform validate run: terraform validate - name: Terraform plan - run: terraform plan -input=false -out=plan.tfplan ${{ inputs.terraform-var-flags }} + # Shell words by contract, quotes included, parsed from a variable rather than pasted into the script by GitHub. + run: | + var_flags=() + eval "var_flags=(${TERRAFORM_VAR_FLAGS})" + terraform plan -input=false -out=plan.tfplan "${var_flags[@]}" env: + TERRAFORM_VAR_FLAGS: ${{ inputs.terraform-var-flags }} # Read by Terraform as arguments of the plan command, the plan of a destruction being applied as any other. TF_CLI_ARGS_plan: ${{ inputs.destroy && '-destroy' || '' }} - name: Terraform apply diff --git a/actions/dotnet/build-test-sonar/action.yml b/actions/dotnet/build-test-sonar/action.yml index d7bcd46..0c90527 100644 --- a/actions/dotnet/build-test-sonar/action.yml +++ b/actions/dotnet/build-test-sonar/action.yml @@ -119,14 +119,18 @@ runs: BUILD_SOURCEBRANCHNAME: ${{ github.ref_name }} BUILD_BUILDID: ${{ github.run_id }} - name: Run tests + # Shell words by contract, quotes included, parsed from a variable rather than pasted into the script by GitHub. run: | + test_args=() + eval "test_args=(${DOTNET_TEST_ARGS})" dotnet test --no-build --verbosity normal --configuration Debug \ --results-directory "$REPORT_FOLDER/TestResults" \ - ${{ inputs.dotnet-test-args }} + "${test_args[@]}" shell: bash working-directory: ${{ inputs.working-directory }} env: ASPNETCORE_ENVIRONMENT: Development + DOTNET_TEST_ARGS: ${{ inputs.dotnet-test-args }} REPORT_FOLDER: ${{ inputs.report-folder }} # Microsoft.Testing.Platform writes the coverage beside the results, and VSTest in a folder of its own for each run. - name: Generate test report diff --git a/actions/dotnet/build-test/action.yml b/actions/dotnet/build-test/action.yml index 125a979..33030a1 100644 --- a/actions/dotnet/build-test/action.yml +++ b/actions/dotnet/build-test/action.yml @@ -26,14 +26,18 @@ runs: BUILD_SOURCEBRANCHNAME: ${{ github.ref_name }} BUILD_BUILDID: ${{ github.run_id }} - name: Run tests + # Shell words by contract, quotes included, parsed from a variable rather than pasted into the script by GitHub. run: | + test_args=() + eval "test_args=(${DOTNET_TEST_ARGS})" dotnet test --no-build --verbosity normal --configuration Debug \ --results-directory "$REPORT_FOLDER/TestResults" \ - ${{ inputs.dotnet-test-args }} + "${test_args[@]}" shell: bash working-directory: ${{ inputs.working-directory }} env: ASPNETCORE_ENVIRONMENT: Development + DOTNET_TEST_ARGS: ${{ inputs.dotnet-test-args }} REPORT_FOLDER: ${{ inputs.report-folder }} # Microsoft.Testing.Platform writes the coverage beside the results, and VSTest in a folder of its own for each run. - name: Generate test report diff --git a/actions/npm/publish/action.yml b/actions/npm/publish/action.yml index 52a3e44..f29e2ed 100644 --- a/actions/npm/publish/action.yml +++ b/actions/npm/publish/action.yml @@ -54,9 +54,14 @@ runs: if: steps.registry.outputs.exists == 'false' shell: bash working-directory: ${{ inputs.working-directory }} - run: ${{ inputs.publish-command }} + env: + PUBLISH_COMMAND: ${{ inputs.publish-command }} + # Shell code by contract, run from a variable rather than pasted into the script by GitHub. + run: eval "$PUBLISH_COMMAND" - name: Check the package without publishing it if: steps.registry.outputs.exists == 'dry-run' shell: bash working-directory: ${{ inputs.working-directory }} - run: ${{ inputs.publish-command }} --dry-run + env: + PUBLISH_COMMAND: ${{ inputs.publish-command }} + run: eval "$PUBLISH_COMMAND --dry-run" diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py index 758bfdf..168c3b2 100755 --- a/test/check_workflow_parts.py +++ b/test/check_workflow_parts.py @@ -18,16 +18,6 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent SELF_REPOSITORY = "devpro/github-workflow-parts" -# Inputs whose value is shell code or shell words by contract, so they are written into the script as they are. -SHELL_INPUTS = { - "inputs.build-command", - "inputs.custom-commands", - "inputs.custom-tests", - "inputs.dotnet-test-args", - "inputs.publish-command", - "inputs.terraform-var-flags", -} - # Parts no CI job can exercise, each with the reason, which the coverage check takes as known. UNTESTED_PARTS = { "actions/fossa/analyze": "needs a FOSSA API key", @@ -204,7 +194,8 @@ def check_run_scripts(path, document): continue label = f"step '{step.get('name', '?')}'" for expression in EXPRESSION.findall(script): - if UNSAFE_IN_RUN.match(expression) and expression not in SHELL_INPUTS: + # Shell code included, which a script runs from a variable with eval. + if UNSAFE_IN_RUN.match(expression): fail(name, f"{label} writes '${{{{ {expression} }}}}' into its script, where an env variable keeps the value from being run as code") if re.search(r"\$\?\s+-(eq|ne)", script): fail(name, f"{label} tests $? after a command, which bash -e never reaches when the command fails") From de050e917e0bd2467a47db34f8b08e12d276eeac Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 00:51:09 +0200 Subject: [PATCH 42/55] Fail the build-push test when an image is left in the daemon, which a negated command never did under errexit Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4dc7f1a..8bf7fb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -325,7 +325,10 @@ jobs: for image in "build-test-base:${GITHUB_RUN_ID}=${BASE_DIGEST}" "build-test-derived:${GITHUB_RUN_ID}=${DERIVED_DIGEST}"; do ref="localhost:5000/${image%%=*}" [ "$(docker buildx imagetools inspect "$ref" --format '{{json .Manifest}}' | jq -r .digest)" = "${image#*=}" ] - ! docker image inspect "$ref" >/dev/null 2>&1 + if docker image inspect "$ref" >/dev/null 2>&1; then + echo "ERROR: ${ref} is left in the daemon" >&2 + exit 1 + fi done test-container-build-push-arm64: From f9e473710e83b91ff0f594476ca4e6bc0954e1f9 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 02:49:06 +0200 Subject: [PATCH 43/55] Let a sonar-project.properties configure the Sonar analysis, the inputs overriding it only when given, and upload the test results of every workspace The inputs replaced the exclusions, the project key and the coverage paths a file sets, since the command line wins over it. The workflow parts are left out of git through .git/info/exclude, which Sonar follows without the exclusions of the project being replaced, where sonar.global.exclusions only works on the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 33 +++++++++++++ .github/workflows/reusable-node-quality.yml | 27 ++++++---- .istarci.yml | 2 +- actions/sonar/scan/action.yml | 49 +++++++++++-------- .../sonar-properties/excluded/skipped.js | 2 + .../sonar-properties/extra/skipped.js | 2 + test/fixtures/sonar-properties/kept.js | 2 + .../sonar-properties/sonar-project.properties | 4 ++ 8 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 test/fixtures/sonar-properties/excluded/skipped.js create mode 100644 test/fixtures/sonar-properties/extra/skipped.js create mode 100644 test/fixtures/sonar-properties/kept.js create mode 100644 test/fixtures/sonar-properties/sonar-project.properties diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bf7fb4..0598de3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -799,6 +799,39 @@ jobs: secrets: sonar-token: ${{ secrets.SONAR_TOKEN }} + # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. + test-sonar-scan-properties: + name: "Test / Sonar scan action with a sonar-project.properties" + if: vars.SONAR_HOST_URL != '' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # As the Node.js quality workflow does with its workflow parts. + - name: Leave a folder of the fixture out of git + run: | + exclude="$(git rev-parse --git-path info/exclude)" + mkdir -p "$(dirname "$exclude")" + echo "/test/fixtures/sonar-properties/extra/" >> "$exclude" + # No project key nor exclusions given: the file holds them. + - name: Analyze the fixture + uses: ./actions/sonar/scan + env: + # The report the scanner sends is kept, for the step below to read which files were indexed. + SONAR_SCANNER_JSON_PARAMS: '{"sonar.scanner.keepReport": "true"}' + with: + host-url: ${{ vars.SONAR_HOST_URL }} + token: ${{ secrets.SONAR_TOKEN }} + working-directory: test/fixtures/sonar-properties + # The token of the run may only send an analysis, so the report the scanner kept tells what it indexed. + - name: Verify the analysis holds only the file neither the exclusions of the file nor git leave out + working-directory: test/fixtures/sonar-properties/.scannerwork + run: | + grep -q '^projectKey=github-workflow-parts-sonar-properties$' report-task.txt + indexed=$(cat scanner-report/component-*.pb | grep -aoE '[a-z/]+\.js' | sort -u | paste -sd ,) + [ "$indexed" = kept.js ] + test-npm-publication: name: "Test / npm publication reusable workflow, without publishing" uses: ./.github/workflows/reusable-npm-publication.yml diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index bfb1558..ac3bda2 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -54,20 +54,20 @@ on: required: false default: false sonar-coverage-paths: - description: lcov reports of the coverage the test script writes, relative to the working directory, separated by commas + description: lcov reports of the coverage the test script writes, relative to the working directory, separated by commas, coverage/lcov.info when empty and the project has no sonar-project.properties type: string required: false - default: "coverage/lcov.info" + default: "" sonar-cpd-exclusions: description: Files or directories that should be excluded from Sonar code duplication analysis type: string required: false default: "" sonar-exclusions: - description: Files or directories that should be excluded from Sonar analysis + description: Files or directories that should be excluded from Sonar analysis, **/node_modules/** when empty and the project has no sonar-project.properties type: string required: false - default: "**/node_modules/**" + default: "" sonar-host-url: description: Sonar host URL type: string @@ -79,7 +79,7 @@ on: required: false default: "" sonar-project-key: - description: Sonar project key + description: Sonar project key, which sonar-project.properties gives otherwise type: string required: false default: "" @@ -174,6 +174,13 @@ jobs: ref: ${{ github.repository == 'devpro/github-workflow-parts' && github.sha || inputs.workflow-parts-version }} path: workflow-parts persist-credentials: false + # Git ignores them, and so does Sonar, which leaves out what git ignores, without overriding the exclusions of the project. + - name: Leave workflow parts out of git + working-directory: . + run: | + exclude="$(git rev-parse --git-path info/exclude)" + mkdir -p "$(dirname "$exclude")" + echo "/workflow-parts/" >> "$exclude" - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -268,8 +275,7 @@ jobs: with: coverage-paths: ${{ inputs.sonar-coverage-paths }} cpd-exclusions: ${{ inputs.sonar-cpd-exclusions }} - # The workflow parts are back beside the repository, and not part of its code. - exclusions: workflow-parts/**,${{ inputs.sonar-exclusions }} + exclusions: ${{ inputs.sonar-exclusions }} host-url: ${{ inputs.sonar-host-url }} organization: ${{ inputs.sonar-organization }} project-key: ${{ inputs.sonar-project-key }} @@ -302,9 +308,10 @@ jobs: uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: node-test-results - # The usual folders of the coverage and of Playwright, and report for anything else, a custom test's server log for instance. + # The usual folders of the coverage and of Playwright, those of a workspace included, and report for anything else, a custom test's server log for instance. path: | ${{ inputs.working-directory }}/report/ ${{ inputs.working-directory }}/coverage/ - ${{ inputs.working-directory }}/playwright-report/ - ${{ inputs.working-directory }}/test-results/ + ${{ inputs.working-directory }}/**/playwright-report/ + ${{ inputs.working-directory }}/**/test-results/ + !${{ inputs.working-directory }}/**/node_modules/** diff --git a/.istarci.yml b/.istarci.yml index da14326..6915d68 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -13,7 +13,7 @@ runner: test-npm-publication: ghcr.io/devpro/debian-node:latest # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] - # The Sonar path of the .NET and Node.js quality workflows runs against a SonarQube of this machine, which GitHub does not have. + # The Sonar paths of the .NET and Node.js quality workflows and of the Sonar action run against a SonarQube of this machine, which GitHub does not have. sonarqube: true workflow: diff --git a/actions/sonar/scan/action.yml b/actions/sonar/scan/action.yml index d1dfd47..112b43d 100644 --- a/actions/sonar/scan/action.yml +++ b/actions/sonar/scan/action.yml @@ -19,17 +19,17 @@ inputs: required: false default: "5e1c9328f4e261838de778c9e586ee608cca45ff7f0538108642219214628ba5" coverage-paths: - description: lcov reports of the coverage, relative to the working directory, separated by commas + description: lcov reports of the coverage, relative to the working directory, separated by commas, coverage/lcov.info when empty and the project has no sonar-project.properties required: false - default: "coverage/lcov.info" + default: "" cpd-exclusions: description: Files or directories that should be excluded from Sonar code duplication analysis required: false default: "" exclusions: - description: Files or directories that should be excluded from Sonar analysis + description: Files or directories that should be excluded from Sonar analysis, **/node_modules/** when empty and the project has no sonar-project.properties required: false - default: "**/node_modules/**" + default: "" host-url: description: Sonar host URL required: true @@ -38,8 +38,9 @@ inputs: required: false default: "" project-key: - description: Sonar project key - required: true + description: Sonar project key, which sonar-project.properties gives otherwise + required: false + default: "" project-name: description: Sonar project name required: false @@ -80,6 +81,7 @@ runs: path: ~/.sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar + # A sonar-project.properties is the configuration of the project, which an input only overrides when it is given. - name: Analyze the code shell: bash working-directory: ${{ inputs.working-directory }} @@ -93,20 +95,25 @@ runs: SONAR_PROJECT_NAME: ${{ inputs.project-name }} SONAR_TOKEN: ${{ inputs.token }} run: | - # The token is read from SONAR_TOKEN, rather than given on the command line any process can list. - args=( - -Dsonar.host.url="$SONAR_HOST_URL" - -Dsonar.projectKey="$SONAR_PROJECT_KEY" - -Dsonar.exclusions="$SONAR_EXCLUSIONS" - -Dsonar.javascript.lcov.reportPaths="$COVERAGE_PATHS" - ) - if [ -n "$SONAR_CPD_EXCLUSIONS" ]; then - args+=(-Dsonar.cpd.exclusions="$SONAR_CPD_EXCLUSIONS") - fi - if [ -n "$SONAR_ORGANIZATION" ]; then - args+=(-Dsonar.organization="$SONAR_ORGANIZATION") - fi - if [ -n "$SONAR_PROJECT_NAME" ]; then - args+=(-Dsonar.projectName="$SONAR_PROJECT_NAME") + if [ ! -f sonar-project.properties ]; then + if [ -z "$SONAR_PROJECT_KEY" ]; then + echo "ERROR: No project-key, and no sonar-project.properties to read it from" >&2 + exit 1 + fi + COVERAGE_PATHS="${COVERAGE_PATHS:-coverage/lcov.info}" + SONAR_EXCLUSIONS="${SONAR_EXCLUSIONS:-**/node_modules/**}" fi + # The token is read from SONAR_TOKEN, rather than given on the command line any process can list. + args=(-Dsonar.host.url="$SONAR_HOST_URL") + add() { + if [ -n "$2" ]; then + args+=("-D$1=$2") + fi + } + add sonar.cpd.exclusions "$SONAR_CPD_EXCLUSIONS" + add sonar.exclusions "$SONAR_EXCLUSIONS" + add sonar.javascript.lcov.reportPaths "$COVERAGE_PATHS" + add sonar.organization "$SONAR_ORGANIZATION" + add sonar.projectKey "$SONAR_PROJECT_KEY" + add sonar.projectName "$SONAR_PROJECT_NAME" sonar-scanner "${args[@]}" diff --git a/test/fixtures/sonar-properties/excluded/skipped.js b/test/fixtures/sonar-properties/excluded/skipped.js new file mode 100644 index 0000000..89cfee4 --- /dev/null +++ b/test/fixtures/sonar-properties/excluded/skipped.js @@ -0,0 +1,2 @@ +// Analyzed only when neither sonar.exclusions nor what git ignores names it. +export const name = "excluded/skipped"; diff --git a/test/fixtures/sonar-properties/extra/skipped.js b/test/fixtures/sonar-properties/extra/skipped.js new file mode 100644 index 0000000..6443303 --- /dev/null +++ b/test/fixtures/sonar-properties/extra/skipped.js @@ -0,0 +1,2 @@ +// Analyzed only when neither sonar.exclusions nor what git ignores names it. +export const name = "extra/skipped"; diff --git a/test/fixtures/sonar-properties/kept.js b/test/fixtures/sonar-properties/kept.js new file mode 100644 index 0000000..cc02e07 --- /dev/null +++ b/test/fixtures/sonar-properties/kept.js @@ -0,0 +1,2 @@ +// Analyzed only when neither sonar.exclusions nor what git ignores names it. +export const name = "kept"; diff --git a/test/fixtures/sonar-properties/sonar-project.properties b/test/fixtures/sonar-properties/sonar-project.properties new file mode 100644 index 0000000..af52792 --- /dev/null +++ b/test/fixtures/sonar-properties/sonar-project.properties @@ -0,0 +1,4 @@ +# The configuration the Sonar action reads, with no input but the connection. +sonar.projectKey=github-workflow-parts-sonar-properties +sonar.sources=. +sonar.exclusions=excluded/** From 5f3c0d89552acd850865de1e2fc81b14beceaf10 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 15:37:35 +0200 Subject: [PATCH 44/55] Add actions linting Go code with the Go toolchain and scanning it with govulncheck The lint checks formatting with gofmt, go.mod and go.sum being tidy, and go vet, with no third party linter. govulncheck is installed with go install, which checks it against the Go checksum database, and fails on a known vulnerability of the code the module calls. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 41 +++++++++++++++++++++ .istarci.yml | 2 + README.md | 2 + actions/go/lint/action.yml | 34 +++++++++++++++++ actions/govulncheck/scan/action.yml | 33 +++++++++++++++++ test/fixtures/go/module/fixture.go | 7 ++++ test/fixtures/go/module/fixture_test.go | 9 +++++ test/fixtures/go/module/go.mod | 3 ++ test/fixtures/go/unformatted/go.mod | 3 ++ test/fixtures/go/unformatted/unformatted.go | 4 ++ test/fixtures/go/untidy/go.mod | 5 +++ test/fixtures/go/untidy/untidy.go | 7 ++++ 12 files changed, 150 insertions(+) create mode 100644 actions/go/lint/action.yml create mode 100644 actions/govulncheck/scan/action.yml create mode 100644 test/fixtures/go/module/fixture.go create mode 100644 test/fixtures/go/module/fixture_test.go create mode 100644 test/fixtures/go/module/go.mod create mode 100644 test/fixtures/go/unformatted/go.mod create mode 100644 test/fixtures/go/unformatted/unformatted.go create mode 100644 test/fixtures/go/untidy/go.mod create mode 100644 test/fixtures/go/untidy/untidy.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0598de3..e3b50b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,6 +186,47 @@ jobs: goreleaser --version command -v goreleaser + test-go-lint-govulncheck-scan: + name: "Test / Go lint and govulncheck scan actions" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: test/fixtures/go/module/go.mod + cache: false + - name: Lint a module that passes every check + uses: ./actions/go/lint + with: + working-directory: test/fixtures/go/module + - name: Scan the module with govulncheck + uses: ./actions/govulncheck/scan + with: + working-directory: test/fixtures/go/module + - name: Lint a module gofmt did not format, failing on it + id: unformatted + continue-on-error: true + uses: ./actions/go/lint + with: + working-directory: test/fixtures/go/unformatted + - name: Lint a module whose go.mod requires what it never imports, failing on it + id: untidy + continue-on-error: true + uses: ./actions/go/lint + with: + working-directory: test/fixtures/go/untidy + - name: Verify both failed and govulncheck is on the path + env: + UNFORMATTED: ${{ steps.unformatted.outcome }} + UNTIDY: ${{ steps.untidy.outcome }} + run: | + [ "$UNFORMATTED" = failure ] + [ "$UNTIDY" = failure ] + govulncheck -version + test-task-setup: name: "Test / Task setup action" runs-on: ubuntu-latest diff --git a/.istarci.yml b/.istarci.yml index 6915d68..046c413 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -11,6 +11,8 @@ runner: test-node-quality-pnpm: ghcr.io/devpro/debian-node:latest test-node-quality-sonar: ghcr.io/devpro/debian-node-playwright:latest test-npm-publication: ghcr.io/devpro/debian-node:latest + # The Go actions need the Go toolchain, which setup-go installs on GitHub and a local run takes from the official image. + test-go-lint-govulncheck-scan: golang:1.26-trixie # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] # The Sonar paths of the .NET and Node.js quality workflows and of the Sonar action run against a SonarQube of this machine, which GitHub does not have. diff --git a/README.md b/README.md index fdc1fca..790ff7c 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ Git: Go: +- [Lint the code with the Go toolchain](actions/go/lint/action.yml) +- [Scan the code with govulncheck](actions/govulncheck/scan/action.yml) - [Setup GoReleaser](actions/goreleaser/setup/action.yml) Helm: diff --git a/actions/go/lint/action.yml b/actions/go/lint/action.yml new file mode 100644 index 0000000..0a3ff25 --- /dev/null +++ b/actions/go/lint/action.yml @@ -0,0 +1,34 @@ +name: Lint Go code +description: | + Checks a Go module with the Go toolchain alone, no third party linter: its files formatted by gofmt, + its go.mod and go.sum tidy, and go vet finding nothing. + Needs Go 1.23 or later on the path, from actions/setup-go or the image of the job. + +inputs: + working-directory: + description: Directory of the Go module (go.mod), relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." + +runs: + using: "composite" + steps: + - name: Check formatting + shell: bash + working-directory: ${{ inputs.working-directory }} + run: | + unformatted="$(gofmt -l .)" + if [ -n "$unformatted" ]; then + echo "ERROR: Files not formatted by gofmt, which gofmt -w fixes:" >&2 + echo "$unformatted" >&2 + exit 1 + fi + # A requirement nothing imports, or one missing, shows here rather than as a surprise in the next build. + - name: Check go.mod and go.sum are tidy + shell: bash + working-directory: ${{ inputs.working-directory }} + run: go mod tidy -diff + - name: Vet + shell: bash + working-directory: ${{ inputs.working-directory }} + run: go vet ./... diff --git a/actions/govulncheck/scan/action.yml b/actions/govulncheck/scan/action.yml new file mode 100644 index 0000000..aea90e3 --- /dev/null +++ b/actions/govulncheck/scan/action.yml @@ -0,0 +1,33 @@ +name: Scan Go code with govulncheck +description: | + Installs govulncheck, the vulnerability checker of the Go team, with go install, which checks the module against the Go checksum database, + then reports the known vulnerabilities of the code the module actually calls, and fails on any. + Needs Go on the path, from actions/setup-go or the image of the job. + +inputs: + govulncheck-version: + description: Version of govulncheck to install (check latest from https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck?tab=versions) + required: false + default: "v1.9.0" + working-directory: + description: Directory of the Go module (go.mod), relative to the workspace, which a composite action does not take from the defaults of the calling job + required: false + default: "." + +runs: + using: "composite" + steps: + - name: Install govulncheck + shell: bash + env: + GOVULNCHECK_VERSION: ${{ inputs.govulncheck-version }} + run: | + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + GOBIN="${RUNNER_TEMP}/bin" go install "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/bin/govulncheck" -version + - name: Scan with govulncheck + shell: bash + working-directory: ${{ inputs.working-directory }} + run: govulncheck ./... diff --git a/test/fixtures/go/module/fixture.go b/test/fixtures/go/module/fixture.go new file mode 100644 index 0000000..4cbfe8f --- /dev/null +++ b/test/fixtures/go/module/fixture.go @@ -0,0 +1,7 @@ +// Package fixture is a Go module the Go actions check, formatted, tidy and with nothing for go vet nor govulncheck to report. +package fixture + +// Add returns the sum of two numbers. +func Add(a, b int) int { + return a + b +} diff --git a/test/fixtures/go/module/fixture_test.go b/test/fixtures/go/module/fixture_test.go new file mode 100644 index 0000000..194561f --- /dev/null +++ b/test/fixtures/go/module/fixture_test.go @@ -0,0 +1,9 @@ +package fixture + +import "testing" + +func TestAdd(t *testing.T) { + if Add(1, 2) != 3 { + t.Fatal("1 + 2 is not 3") + } +} diff --git a/test/fixtures/go/module/go.mod b/test/fixtures/go/module/go.mod new file mode 100644 index 0000000..360e099 --- /dev/null +++ b/test/fixtures/go/module/go.mod @@ -0,0 +1,3 @@ +module example.com/fixture + +go 1.26 diff --git a/test/fixtures/go/unformatted/go.mod b/test/fixtures/go/unformatted/go.mod new file mode 100644 index 0000000..7d2ded8 --- /dev/null +++ b/test/fixtures/go/unformatted/go.mod @@ -0,0 +1,3 @@ +module example.com/unformatted + +go 1.26 diff --git a/test/fixtures/go/unformatted/unformatted.go b/test/fixtures/go/unformatted/unformatted.go new file mode 100644 index 0000000..6475340 --- /dev/null +++ b/test/fixtures/go/unformatted/unformatted.go @@ -0,0 +1,4 @@ +// Package unformatted is not formatted by gofmt, which the lint action fails on. +package unformatted + +func Value( ) int {return 1} diff --git a/test/fixtures/go/untidy/go.mod b/test/fixtures/go/untidy/go.mod new file mode 100644 index 0000000..210e414 --- /dev/null +++ b/test/fixtures/go/untidy/go.mod @@ -0,0 +1,5 @@ +module example.com/untidy + +go 1.26 + +require golang.org/x/text v0.31.0 diff --git a/test/fixtures/go/untidy/untidy.go b/test/fixtures/go/untidy/untidy.go new file mode 100644 index 0000000..97c68c0 --- /dev/null +++ b/test/fixtures/go/untidy/untidy.go @@ -0,0 +1,7 @@ +// Package untidy requires a module it never imports, which go mod tidy would take out and the lint action fails on. +package untidy + +// Value returns one. +func Value() int { + return 1 +} From 89737d9212f3acf0211ece7347ec6eb93b8d5f2b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 16:48:59 +0200 Subject: [PATCH 45/55] Build a site by convention in the Pages workflow when no build command is given, on Node.js 24 by default npm ci, npm install for a site without a lock file, then the build script, as the Node.js quality workflow does, so a site whose build is its build script passes no command. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 8 ++++++++ .../workflows/reusable-pages-deployment.yml | 19 +++++++++++++++---- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3b50b9..97fc330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -791,6 +791,14 @@ jobs: additional-vars: | FIXTURE_SECRET=hidden + test-pages-deployment-conventions: + name: "Test / Pages deployment reusable workflow, building by convention" + uses: ./.github/workflows/reusable-pages-deployment.yml + with: + deploy: false + site-path: _site + working-directory: test/fixtures/pages + test-node-quality-npm: name: "Test / Node.js quality reusable workflow, with npm" uses: ./.github/workflows/reusable-node-quality.yml diff --git a/.github/workflows/reusable-pages-deployment.yml b/.github/workflows/reusable-pages-deployment.yml index b2e4dc0..0e1de31 100644 --- a/.github/workflows/reusable-pages-deployment.yml +++ b/.github/workflows/reusable-pages-deployment.yml @@ -5,9 +5,10 @@ on: workflow_call: inputs: build-command: - description: Commands building the site into site-path, run from the working directory + description: Commands building the site into site-path, run from the working directory, npm ci (npm install without a lock file) then npm run build when empty type: string - required: true + required: false + default: "" deploy: description: Deploy the site once built, false building it only, to check a pull request for instance type: boolean @@ -27,7 +28,7 @@ on: description: Version of Node.js to install, with the npm cache of the lock file when there is one, none when empty type: string required: false - default: "" + default: "24" operating-system: description: Operating system executing the runner type: string @@ -116,7 +117,17 @@ jobs: env: BUILD_COMMAND: ${{ inputs.build-command }} # Shell code by contract, run from a variable rather than pasted into the script by GitHub. - run: eval "$BUILD_COMMAND" + run: | + if [[ -n "$BUILD_COMMAND" ]]; then + eval "$BUILD_COMMAND" + else + if [[ -f package-lock.json ]]; then + npm ci + else + npm install + fi + npm run build + fi - name: Verify the site was built env: SITE_PATH: ${{ inputs.site-path }} From cf0d1a4e4cd55c9a1361c5c828c9452dc7c5569e Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 16:48:59 +0200 Subject: [PATCH 46/55] Add an action submitting the pages of a sitemap to IndexNow, with curl and jq rather than a third party action It follows a sitemap index, decodes the addresses as XML writes them, sends at most 10,000 a request and fails on a submission IndexNow refuses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 43 ++++++++++++++++ README.md | 4 ++ actions/indexnow/submit/action.yml | 82 ++++++++++++++++++++++++++++++ test/fixtures/indexnow/server.js | 35 +++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 actions/indexnow/submit/action.yml create mode 100644 test/fixtures/indexnow/server.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97fc330..da8c557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -227,6 +227,49 @@ jobs: [ "$UNTIDY" = failure ] govulncheck -version + test-indexnow-submit: + name: "Test / IndexNow submit action, against a local API" + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # The server outlives this step, which the next ones reach, and goes with the job. + - name: Serve a sitemap index and an IndexNow API that records the submission + run: | + nohup node test/fixtures/indexnow/server.js "${RUNNER_TEMP}/indexnow.json" 18089 > "${RUNNER_TEMP}/indexnow.log" 2>&1 & + for _ in $(seq 1 20); do + curl -fs -o /dev/null http://127.0.0.1:18089/sitemap.xml && exit 0 + sleep 0.5 + done + cat "${RUNNER_TEMP}/indexnow.log" + exit 1 + - name: Submit the pages of the sitemap index + uses: ./actions/indexnow/submit + with: + api-url: http://127.0.0.1:18089/indexnow + key: fixture-key + sitemap-url: http://127.0.0.1:18089/sitemap.xml + - name: Submit to an API refusing the submission, failing on it + id: refused + continue-on-error: true + uses: ./actions/indexnow/submit + with: + api-url: http://127.0.0.1:18089/refuse + key: fixture-key + sitemap-url: http://127.0.0.1:18089/sitemap.xml + - name: Verify the submission and the refusal + env: + REFUSED: ${{ steps.refused.outcome }} + run: | + jq -e ' + .host == "127.0.0.1:18089" + and .key == "fixture-key" + and .keyLocation == "http://127.0.0.1:18089/fixture-key.txt" + and .urlList == ["http://127.0.0.1:18089/a?x=1&y=2", "http://127.0.0.1:18089/b", "http://127.0.0.1:18089/c"] + ' "${RUNNER_TEMP}/indexnow.json" + [ "$REFUSED" = failure ] + test-task-setup: name: "Test / Task setup action" runs-on: ubuntu-latest diff --git a/README.md b/README.md index 790ff7c..6929109 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ Node.js: - [Publish a package unless its version already is](actions/npm/publish/action.yml) - [Install the Playwright browsers](actions/playwright/install/action.yml) +Search engines: + +- [Submit the pages of a sitemap to IndexNow](actions/indexnow/submit/action.yml) + Sonar: - [Analyze the code of a project other than .NET](actions/sonar/scan/action.yml) diff --git a/actions/indexnow/submit/action.yml b/actions/indexnow/submit/action.yml new file mode 100644 index 0000000..31a2d67 --- /dev/null +++ b/actions/indexnow/submit/action.yml @@ -0,0 +1,82 @@ +name: Submit pages to IndexNow +description: | + Reads the pages of a site from its sitemap, a sitemap index included, and submits them to IndexNow, + which passes them on to the search engines taking part (Bing, Yandex, Naver, Seznam and others), + with curl and jq rather than a third party action. + The site serves the key at key-location, which proves it owns the key. + +inputs: + api-url: + description: Address of the IndexNow API the pages are submitted to + required: false + default: "https://api.indexnow.org/indexnow" + key: + description: IndexNow key of the site, kept in a secret, since it stands for the site with IndexNow + required: true + key-location: + description: Address of the file holding the key, https:///.txt of the host of the sitemap when empty + required: false + default: "" + sitemap-url: + description: Address of the sitemap (sitemap.xml) of the site, or of its sitemap index + required: true + +runs: + using: "composite" + steps: + - name: Submit the pages of the sitemap + shell: bash + env: + API_URL: ${{ inputs.api-url }} + INDEXNOW_KEY: ${{ inputs.key }} + KEY_LOCATION: ${{ inputs.key-location }} + SITEMAP_URL: ${{ inputs.sitemap-url }} + run: | + echo "::add-mask::$INDEXNOW_KEY" + origin="$(echo "$SITEMAP_URL" | sed -E 's#^(https?://[^/]+).*#\1#')" + host="${origin#*://}" + KEY_LOCATION="${KEY_LOCATION:-${origin}/${INDEXNOW_KEY}.txt}" + + # The addresses of the elements, which XML writes with & as &. + locations() { + curl -fsSL "$1" | tr -d '\r\n' | grep -o '[^<]*' | sed -e 's###g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ + -e 's/<//g' -e "s/'/'/g" -e 's/"/"/g' -e 's/&/\&/g' + } + + urls=() + # Read whole, since grep -q leaving a pipe early fails the curl writing to it, under pipefail. + sitemap_content="$(curl -fsSL "$SITEMAP_URL")" + if [[ "$sitemap_content" == *"&2 + exit 1 + fi + echo "Submitting ${#urls[@]} page(s) of ${host} to ${API_URL}" + + # IndexNow takes at most 10,000 addresses a request. + for ((start = 0; start < ${#urls[@]}; start += 10000)); do + payload="$(mktemp)" + jq -n --arg host "$host" --arg key "$INDEXNOW_KEY" --arg keyLocation "$KEY_LOCATION" \ + '{host: $host, key: $key, keyLocation: $keyLocation, urlList: $ARGS.positional}' \ + --args "${urls[@]:start:10000}" > "$payload" + response="$(mktemp)" + status="$(curl -sS -o "$response" -w '%{http_code}' -H 'Content-Type: application/json; charset=utf-8' --data-binary "@$payload" "$API_URL")" + # 200 is a submission taken, 202 one taken before the key is checked. + if [[ "$status" != 200 && "$status" != 202 ]]; then + echo "ERROR: IndexNow answered ${status}" >&2 + cat "$response" >&2 + echo >&2 + exit 1 + fi + echo "IndexNow answered ${status}" + done diff --git a/test/fixtures/indexnow/server.js b/test/fixtures/indexnow/server.js new file mode 100644 index 0000000..1cfe1e2 --- /dev/null +++ b/test/fixtures/indexnow/server.js @@ -0,0 +1,35 @@ +// Serves a sitemap index of two sitemaps, and an IndexNow API that records what it is sent, for the IndexNow action to be tested without reaching IndexNow. +// Usage: node server.js +const fs = require("node:fs"); +const http = require("node:http"); + +const [recordPath, port] = process.argv.slice(2); +const origin = `http://127.0.0.1:${port}`; +const urlset = (...locs) => + `\n\n${locs.map((loc) => ` \n \n ${loc}\n \n `).join("\n")}\n\n`; +const pages = { + "/sitemap.xml": `\n\n ${origin}/pages.xml\n ${origin}/posts.xml\n\n`, + "/pages.xml": urlset(`${origin}/a?x=1&y=2`, `${origin}/b`), + "/posts.xml": urlset(`${origin}/c`), +}; + +http + .createServer((req, res) => { + if (req.method === "GET" && pages[req.url]) { + res.writeHead(200, { "content-type": "application/xml" }).end(pages[req.url]); + return; + } + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + if (req.method === "POST" && req.url === "/indexnow") { + fs.writeFileSync(recordPath, body); + res.writeHead(202).end(); + } else if (req.method === "POST" && req.url === "/refuse") { + res.writeHead(422).end("Unprocessable Entity: the URLs do not belong to the host, or the key does not match the schema"); + } else { + res.writeHead(404).end(); + } + }); + }) + .listen(Number(port), "127.0.0.1"); From bbf341ec77510dacdfbb04ca5af7947dd8f6fb47 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 17:24:41 +0200 Subject: [PATCH 47/55] Let the quality workflows start the MongoDB version a repository asks for, the latest release by default The version the action defaults to is written in the action alone, so moving to the next release is one change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 18 +++++++++++++++++- .github/workflows/reusable-dotnet-quality.yml | 7 +++++++ .github/workflows/reusable-node-quality.yml | 6 ++++++ actions/mongodb/start/action.yml | 7 ++++--- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da8c557..48b655c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -495,9 +495,25 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Start MongoDB uses: ./actions/mongodb/start - - name: Verify MongoDB answers + - name: Verify MongoDB answers, on the latest release by default run: | mongosh --quiet --eval 'db.runCommand({ ping: 1 }).ok' mongodb://127.0.0.1:27017 | grep -qx 1 + mongosh --quiet --eval 'db.version()' mongodb://127.0.0.1:27017 | grep -q '^9\.0\.' + + test-mongodb-start-version: + name: "Test / MongoDB start action, an earlier version" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Start MongoDB 8.0 + uses: ./actions/mongodb/start + with: + mongodb-version: 8.0.34 + - name: Verify the server is the version asked for + run: | + [ "$(mongosh --quiet --eval 'db.version()' mongodb://127.0.0.1:27017)" = 8.0.34 ] test-mongodb-start-replica-set: name: "Test / MongoDB start action, replica set" diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index cf1ef6c..14cec26 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -38,6 +38,11 @@ on: type: boolean required: false default: false + mongodb-version: + description: Version of the MongoDB server mongodb-enabled starts, an 8.0 one for instance, the latest release the MongoDB action names when empty + type: string + required: false + default: "" job-name: description: Job name type: string @@ -166,6 +171,8 @@ jobs: - name: Start MongoDB if: ${{ inputs.mongodb-enabled }} uses: ./workflow-parts/actions/mongodb/start + with: + mongodb-version: ${{ inputs.mongodb-version }} - name: Run optional custom commands if: ${{ inputs.custom-commands != '' }} env: diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index ac3bda2..3a28cfa 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -33,6 +33,11 @@ on: type: boolean required: false default: false + mongodb-version: + description: Version of the MongoDB server mongodb-enabled starts, an 8.0 one for instance, the latest release the MongoDB action names when empty + type: string + required: false + default: "" node-version: description: Node.js version type: string @@ -242,6 +247,7 @@ jobs: uses: ./workflow-parts/actions/mongodb/start with: replica-set: rs0 + mongodb-version: ${{ inputs.mongodb-version }} # The scripts lint, format and test the repository, which the workflow parts are no part of. - name: Set aside workflow parts working-directory: . diff --git a/actions/mongodb/start/action.yml b/actions/mongodb/start/action.yml index 614ffdb..16d3d50 100644 --- a/actions/mongodb/start/action.yml +++ b/actions/mongodb/start/action.yml @@ -7,9 +7,9 @@ description: | inputs: mongodb-version: - description: Version of the MongoDB server (check latest from https://www.mongodb.com/try/download/community) + description: Version of the MongoDB server, an 8.0 one for instance, the latest release (9.0.4) when empty (check latest from https://www.mongodb.com/try/download/community) required: false - default: "9.0.4" + default: "" mongosh-version: description: Version of mongosh (check latest from https://github.com/mongodb-js/mongosh/releases) required: false @@ -37,7 +37,8 @@ runs: - name: Install & start MongoDB shell: bash env: - MONGODB_ARCHIVE: mongodb-linux-${{ runner.arch == 'ARM64' && 'aarch64' || 'x86_64' }}-ubuntu2404-${{ inputs.mongodb-version }}.tgz + # The one place naming the default version, which the workflows calling this action leave empty. + MONGODB_ARCHIVE: mongodb-linux-${{ runner.arch == 'ARM64' && 'aarch64' || 'x86_64' }}-ubuntu2404-${{ inputs.mongodb-version || '9.0.4' }}.tgz MONGOSH_VERSION: ${{ inputs.mongosh-version }} MONGOSH_ARCHIVE: mongosh-${{ inputs.mongosh-version }}-linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}.tgz MONGOSH_SHA256: ${{ runner.arch == 'ARM64' && inputs.mongosh-sha256-arm64 || inputs.mongosh-sha256 }} From 3f5c3623b3ed2e1c59924764f492b7bc2346bd61 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 22:41:13 +0200 Subject: [PATCH 48/55] Add actions installing kube-linter, kubeconform, helm-unittest, and kind with kubectl, each checked against its SHA256 Each downloads the official release and checks it against the checksum file the release publishes, or a hash pinned here for kube-linter, whose release has none. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 63 +++++++++++++++++++++++++- README.md | 7 +++ actions/helm-unittest/setup/action.yml | 37 +++++++++++++++ actions/kind/setup/action.yml | 44 ++++++++++++++++++ actions/kube-linter/setup/action.yml | 40 ++++++++++++++++ actions/kubeconform/setup/action.yml | 33 ++++++++++++++ 6 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 actions/helm-unittest/setup/action.yml create mode 100644 actions/kind/setup/action.yml create mode 100644 actions/kube-linter/setup/action.yml create mode 100644 actions/kubeconform/setup/action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48b655c..ba544c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,15 @@ jobs: uses: ./actions/task/setup with: task-version: v3.52.0 + - name: Setup kubeconform + uses: ./actions/kubeconform/setup + with: + kubeconform-version: v0.7.0 + - name: Setup kind + uses: ./actions/kind/setup + with: + kind-version: v0.32.0 + kubectl-version: v1.36.4 - name: Verify the versions run: | # Compared once captured, since a reader closing the pipe early fails the step with SIGPIPE under pipefail. @@ -87,6 +96,9 @@ jobs: [[ "$(tflint --version)" == *"version 0.53.0"* ]] [[ "$(helm version --short)" == v3.16.4+* ]] [[ "$(task --version)" == *3.52.0* ]] + [[ "$(kubeconform -v)" == v0.7.0 ]] + [[ "$(kind version)" == "kind v0.32.0 "* ]] + [[ "$(kubectl version --client)" == *"v1.36.4"* ]] # A setup action that cannot verify what it downloaded stops the job rather than running it. test-setup-fails-closed: @@ -126,6 +138,18 @@ jobs: uses: ./actions/task/setup with: task-version: v0.0.0 + - name: Install kube-linter with a checksum that does not match + id: kube-linter-wrong-checksum + continue-on-error: true + uses: ./actions/kube-linter/setup + with: + kube-linter-sha256: "0000000000000000000000000000000000000000000000000000000000000000" + - name: Install a kind release that does not exist + id: kind-missing-release + continue-on-error: true + uses: ./actions/kind/setup + with: + kind-version: v0.0.0 - name: Start MongoDB with a mongosh archive whose checksum does not match id: mongodb-wrong-checksum continue-on-error: true @@ -156,6 +180,35 @@ jobs: helm version command -v helm + test-kubernetes-tools-setup: + name: "Test / kube-linter, kubeconform, helm-unittest and kind setup actions" + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Helm + uses: ./actions/helm/setup + - name: Setup kube-linter + uses: ./actions/kube-linter/setup + - name: Setup kubeconform + uses: ./actions/kubeconform/setup + - name: Setup helm-unittest + uses: ./actions/helm-unittest/setup + - name: Setup kind + uses: ./actions/kind/setup + - name: Verify the tools run on a chart + run: | + helm create "${RUNNER_TEMP}/demo" >/dev/null + helm template demo "${RUNNER_TEMP}/demo" | kubeconform -strict -summary + kube-linter lint "${RUNNER_TEMP}/demo" --format json > "${RUNNER_TEMP}/kube-linter.json" || true + jq -e '.Reports != null' "${RUNNER_TEMP}/kube-linter.json" + mkdir -p "${RUNNER_TEMP}/demo/tests" + printf '%s\n' 'suite: deployment' 'templates: [templates/deployment.yaml]' 'tests:' ' - it: renders a deployment' ' asserts:' ' - isKind:' ' of: Deployment' > "${RUNNER_TEMP}/demo/tests/deployment_test.yaml" + untt "${RUNNER_TEMP}/demo" + kind version + kubectl version --client + test-chart-testing-setup: name: "Test / chart-testing setup action" runs-on: ubuntu-latest @@ -467,6 +520,14 @@ jobs: uses: ./actions/goreleaser/setup - name: Setup Task uses: ./actions/task/setup + - name: Setup kube-linter + uses: ./actions/kube-linter/setup + - name: Setup kubeconform + uses: ./actions/kubeconform/setup + - name: Setup helm-unittest + uses: ./actions/helm-unittest/setup + - name: Setup kind + uses: ./actions/kind/setup - name: Start MongoDB uses: ./actions/mongodb/start - name: Generate SBOM with Syft @@ -481,7 +542,7 @@ jobs: target: test/fixtures/container - name: Verify the binaries are for arm64 run: | - for tool in terraform tflint helm ct goreleaser task mongod mongosh syft trivy; do + for tool in terraform tflint helm ct goreleaser task kube-linter kubeconform untt kind kubectl mongod mongosh syft trivy; do # The ELF machine field, 0xb7 being aarch64, read without the file command a runner image may lack. [ "$(od -An -tx1 -j18 -N1 "$(command -v "$tool")" | tr -d ' ')" = b7 ] done diff --git a/README.md b/README.md index 6929109..1c71924 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,13 @@ Helm: - [Setup Helm](actions/helm/setup/action.yml) - [Setup chart-testing](actions/chart-testing/setup/action.yml) +- [Setup helm-unittest](actions/helm-unittest/setup/action.yml) +- [Setup kube-linter](actions/kube-linter/setup/action.yml) +- [Setup kubeconform](actions/kubeconform/setup/action.yml) + +Kubernetes: + +- [Setup kind and kubectl](actions/kind/setup/action.yml) License compliance: diff --git a/actions/helm-unittest/setup/action.yml b/actions/helm-unittest/setup/action.yml new file mode 100644 index 0000000..7d48aa2 --- /dev/null +++ b/actions/helm-unittest/setup/action.yml @@ -0,0 +1,37 @@ +name: Setup helm-unittest +description: | + Installs untt, the standalone binary of the helm-unittest plugin, so no plugin is installed into Helm, + by downloading the official release and verifying its SHA256 checksum. + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + helm-unittest-version: + description: Version of helm-unittest to install, without its leading v (check latest from https://github.com/helm-unittest/helm-unittest/releases) + required: false + default: "1.2.1" + +runs: + using: "composite" + steps: + - name: Install helm-unittest + shell: bash + env: + HELM_UNITTEST_VERSION: ${{ inputs.helm-unittest-version }} + HELM_UNITTEST_PLATFORM: linux-${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }} + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + BASE_URL="https://github.com/helm-unittest/helm-unittest/releases/download/v${HELM_UNITTEST_VERSION}" + archive="helm-unittest-${HELM_UNITTEST_PLATFORM}-${HELM_UNITTEST_VERSION}.tgz" + curl -fsSL -O "${BASE_URL}/${archive}" + curl -fsSL -O "${BASE_URL}/helm-unittest-checksum.sha" + # The checksum file names each archive by the path it was built at, ./_dist/. + checksum="$(grep "/${archive}$" helm-unittest-checksum.sha | cut -d ' ' -f 1)" + echo "${checksum} ${archive}" | sha256sum --check --status + tar -xzf "${archive}" "untt-${HELM_UNITTEST_PLATFORM}" + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + mv "untt-${HELM_UNITTEST_PLATFORM}" "${RUNNER_TEMP}/bin/untt" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + # untt has no version flag, so it is only checked to run. + "${RUNNER_TEMP}/bin/untt" --help > /dev/null diff --git a/actions/kind/setup/action.yml b/actions/kind/setup/action.yml new file mode 100644 index 0000000..d9ed5ff --- /dev/null +++ b/actions/kind/setup/action.yml @@ -0,0 +1,44 @@ +name: Setup kind +description: | + Installs kind, which runs a Kubernetes cluster in containers, and kubectl, + by downloading the official binaries and verifying their SHA256 checksums + (replacement for helm/kind-action, a JavaScript action, which a container job cannot run without Node.js). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + kind-version: + description: Version of kind to install (check latest from https://github.com/kubernetes-sigs/kind/releases) + required: false + default: "v0.33.0" + kubectl-version: + description: Version of kubectl to install, within one minor version of the clusters it talks to (check latest from https://dl.k8s.io/release/stable.txt) + required: false + default: "v1.37.1" + +runs: + using: "composite" + steps: + - name: Install kind and kubectl + shell: bash + env: + ARCH: ${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }} + KIND_VERSION: ${{ inputs.kind-version }} + KUBECTL_VERSION: ${{ inputs.kubectl-version }} + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + KIND_BINARY="kind-linux-${ARCH}" + curl -fsSL -O "https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/${KIND_BINARY}" + curl -fsSL -O "https://github.com/kubernetes-sigs/kind/releases/download/${KIND_VERSION}/${KIND_BINARY}.sha256sum" + sha256sum --check --status "${KIND_BINARY}.sha256sum" + KUBECTL_URL="https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" + curl -fsSL -O "$KUBECTL_URL" + # The checksum file of kubectl holds the hash alone. + echo "$(curl -fsSL "${KUBECTL_URL}.sha256") kubectl" | sha256sum --check --status + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + install -m 0755 "${KIND_BINARY}" "${RUNNER_TEMP}/bin/kind" + install -m 0755 kubectl "${RUNNER_TEMP}/bin/kubectl" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/bin/kind" version + "${RUNNER_TEMP}/bin/kubectl" version --client diff --git a/actions/kube-linter/setup/action.yml b/actions/kube-linter/setup/action.yml new file mode 100644 index 0000000..f14db84 --- /dev/null +++ b/actions/kube-linter/setup/action.yml @@ -0,0 +1,40 @@ +name: Setup kube-linter +description: | + Installs kube-linter by downloading the official binary from its GitHub release and verifying its SHA256 checksum, pinned here since the release publishes none + (replacement for stackrox/kube-linter-action, to reduce third-party GitHub Action supply-chain risk). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + kube-linter-version: + description: Version of kube-linter to install (check latest from https://github.com/stackrox/kube-linter/releases) + required: false + default: "v0.8.3" + kube-linter-sha256: + description: SHA256 of the kube-linter archive for x64, given with any other kube-linter-version, since its release publishes no checksum file + required: false + default: "1a6d8419b11971372971fdbc22682b684ebfb7cf1c39591662d1b6ca736c41df" + kube-linter-sha256-arm64: + description: SHA256 of the kube-linter archive for arm64, given with any other kube-linter-version + required: false + default: "802e1b09eabd08f6f0a060a6b8ab2bf7bc7e6bf4f673bb2692303704c84b3e22" + +runs: + using: "composite" + steps: + - name: Install kube-linter + shell: bash + env: + KUBE_LINTER_VERSION: ${{ inputs.kube-linter-version }} + KUBE_LINTER_TARBALL: kube-linter-linux${{ runner.arch == 'ARM64' && '_arm64' || '' }}.tar.gz + KUBE_LINTER_SHA256: ${{ runner.arch == 'ARM64' && inputs.kube-linter-sha256-arm64 || inputs.kube-linter-sha256 }} + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + curl -fsSL -O "https://github.com/stackrox/kube-linter/releases/download/${KUBE_LINTER_VERSION}/${KUBE_LINTER_TARBALL}" + echo "${KUBE_LINTER_SHA256} ${KUBE_LINTER_TARBALL}" | sha256sum --check --status + tar -xzf "${KUBE_LINTER_TARBALL}" kube-linter + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + mv kube-linter "${RUNNER_TEMP}/bin/kube-linter" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/bin/kube-linter" version diff --git a/actions/kubeconform/setup/action.yml b/actions/kubeconform/setup/action.yml new file mode 100644 index 0000000..8491279 --- /dev/null +++ b/actions/kubeconform/setup/action.yml @@ -0,0 +1,33 @@ +name: Setup kubeconform +description: | + Installs kubeconform, which validates Kubernetes manifests against the schemas of the API, + by downloading the official binary from its GitHub release and verifying its SHA256 checksum. + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + kubeconform-version: + description: Version of kubeconform to install (check latest from https://github.com/yannh/kubeconform/releases) + required: false + default: "v0.8.0" + +runs: + using: "composite" + steps: + - name: Install kubeconform + shell: bash + env: + KUBECONFORM_VERSION: ${{ inputs.kubeconform-version }} + KUBECONFORM_TARBALL: kubeconform-linux-${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }}.tar.gz + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + BASE_URL="https://github.com/yannh/kubeconform/releases/download/${KUBECONFORM_VERSION}" + curl -fsSL -O "${BASE_URL}/${KUBECONFORM_TARBALL}" + curl -fsSL -O "${BASE_URL}/CHECKSUMS" + grep " ${KUBECONFORM_TARBALL}$" CHECKSUMS | sha256sum --check --status + tar -xzf "${KUBECONFORM_TARBALL}" kubeconform + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + mv kubeconform "${RUNNER_TEMP}/bin/kubeconform" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/bin/kubeconform" -v From 13ac7ccf4ba593797f8c80a75f2559ce0fc2d09e Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Thu, 22 Oct 2026 23:54:46 +0200 Subject: [PATCH 49/55] Add actions installing Opengrep, Trivy and CodeQL, each checked against its SHA256, for jobs running the scanners themselves Opengrep replaces its install.sh, run from the main branch of the project, with its release binary and a hash pinned here, the release publishing signatures but no checksum file. CodeQL comes from the bundle of the codeql-action releases, the CLI with its query packs, checked against the checksum file beside it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SESuYdZsJfq852odntm3Yi --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++- README.md | 6 ++++ actions/codeql/setup/action.yml | 33 ++++++++++++++++++++++ actions/opengrep/setup/action.yml | 39 +++++++++++++++++++++++++ actions/trivy/setup/action.yml | 47 +++++++++++++++++++++++++++++++ 5 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 actions/codeql/setup/action.yml create mode 100644 actions/opengrep/setup/action.yml create mode 100644 actions/trivy/setup/action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba544c7..ae8f07c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,6 +144,12 @@ jobs: uses: ./actions/kube-linter/setup with: kube-linter-sha256: "0000000000000000000000000000000000000000000000000000000000000000" + - name: Install Opengrep with a checksum that does not match + id: opengrep-wrong-checksum + continue-on-error: true + uses: ./actions/opengrep/setup + with: + opengrep-sha256: "0000000000000000000000000000000000000000000000000000000000000000" - name: Install a kind release that does not exist id: kind-missing-release continue-on-error: true @@ -209,6 +215,30 @@ jobs: kind version kubectl version --client + test-scanner-setup: + name: "Test / Opengrep, Trivy and CodeQL setup actions" + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Setup Opengrep + uses: ./actions/opengrep/setup + - name: Setup Trivy + uses: ./actions/trivy/setup + - name: Setup CodeQL + uses: ./actions/codeql/setup + - name: Verify each scanner runs + run: | + mkdir -p "${RUNNER_TEMP}/opengrep-fixture" + printf '%s\n' 'const input = process.argv[2];' 'eval(input);' > "${RUNNER_TEMP}/opengrep-fixture/app.js" + # $X is a metavariable of the Opengrep pattern, which the shell must leave as it is. + # shellcheck disable=SC2016 + [ "$(opengrep scan --quiet --json -e 'eval($X)' --lang js "${RUNNER_TEMP}/opengrep-fixture" | jq '.results | length')" = 1 ] + trivy fs --quiet --scanners vuln --exit-code 0 test/fixtures/node + [[ "$(trivy --version)" == *"Version: "* ]] + codeql resolve languages --format=json | jq -e 'has("javascript") and has("python")' + test-chart-testing-setup: name: "Test / chart-testing setup action" runs-on: ubuntu-latest @@ -528,6 +558,10 @@ jobs: uses: ./actions/helm-unittest/setup - name: Setup kind uses: ./actions/kind/setup + - name: Setup Opengrep + uses: ./actions/opengrep/setup + - name: Setup CodeQL + uses: ./actions/codeql/setup - name: Start MongoDB uses: ./actions/mongodb/start - name: Generate SBOM with Syft @@ -542,10 +576,12 @@ jobs: target: test/fixtures/container - name: Verify the binaries are for arm64 run: | - for tool in terraform tflint helm ct goreleaser task kube-linter kubeconform untt kind kubectl mongod mongosh syft trivy; do + for tool in terraform tflint helm ct goreleaser task kube-linter kubeconform untt kind kubectl opengrep mongod mongosh syft trivy; do # The ELF machine field, 0xb7 being aarch64, read without the file command a runner image may lack. [ "$(od -An -tx1 -j18 -N1 "$(command -v "$tool")" | tr -d ' ')" = b7 ] done + # The codeql command is a launcher script, run here so that its arm64 Java and extractors answer. + codeql version --format=terse test-mongodb-start: name: "Test / MongoDB start action" diff --git a/README.md b/README.md index 1c71924..282eea4 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Containers: - [Sign an image with Cosign](actions/cosign/sign/action.yml) - [Setup QEMU to build images for other platforms](actions/qemu/setup/action.yml) - [Scan an image or a directory with Trivy](actions/trivy/scan/action.yml) +- [Setup Trivy](actions/trivy/setup/action.yml) .NET: @@ -104,6 +105,11 @@ Search engines: - [Submit the pages of a sitemap to IndexNow](actions/indexnow/submit/action.yml) +Code scanning: + +- [Setup CodeQL](actions/codeql/setup/action.yml) +- [Setup Opengrep](actions/opengrep/setup/action.yml) + Sonar: - [Analyze the code of a project other than .NET](actions/sonar/scan/action.yml) diff --git a/actions/codeql/setup/action.yml b/actions/codeql/setup/action.yml new file mode 100644 index 0000000..f706aab --- /dev/null +++ b/actions/codeql/setup/action.yml @@ -0,0 +1,33 @@ +name: Setup CodeQL +description: | + Installs the CodeQL CLI with its standard query packs, from the CodeQL bundle the codeql-action project releases, + verifying its SHA256 checksum, for a job running CodeQL itself rather than through github/codeql-action. + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + codeql-version: + description: Version of the CodeQL bundle to install, without its leading v (check latest from https://github.com/github/codeql-action/releases) + required: false + default: "2.27.2" + +runs: + using: "composite" + steps: + - name: Install CodeQL + shell: bash + env: + CODEQL_VERSION: ${{ inputs.codeql-version }} + CODEQL_BUNDLE: codeql-bundle-${{ runner.arch == 'ARM64' && 'linux-arm64' || 'linux64' }}.tar.gz + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the bundle. + cd "$(mktemp -d)" + BASE_URL="https://github.com/github/codeql-action/releases/download/codeql-bundle-v${CODEQL_VERSION}" + curl -fsSL -O "${BASE_URL}/${CODEQL_BUNDLE}" + curl -fsSL -O "${BASE_URL}/${CODEQL_BUNDLE}.checksum.txt" + sha256sum --check --status "${CODEQL_BUNDLE}.checksum.txt" + # The bundle is a codeql directory holding the CLI and the query packs it resolves beside itself. + rm -rf "${RUNNER_TEMP}/codeql" + tar -xzf "${CODEQL_BUNDLE}" -C "${RUNNER_TEMP}" + rm -f "${CODEQL_BUNDLE}" "${CODEQL_BUNDLE}.checksum.txt" + echo "${RUNNER_TEMP}/codeql" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/codeql/codeql" version --format=terse diff --git a/actions/opengrep/setup/action.yml b/actions/opengrep/setup/action.yml new file mode 100644 index 0000000..0aebcd0 --- /dev/null +++ b/actions/opengrep/setup/action.yml @@ -0,0 +1,39 @@ +name: Setup Opengrep +description: | + Installs Opengrep by downloading the official binary from its GitHub release and verifying its SHA256 checksum, pinned here since the release publishes signatures but no checksum file + (replacement for its install.sh, which a pipeline would run from the main branch of the project, unpinned). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + opengrep-version: + description: Version of Opengrep to install (check latest from https://github.com/opengrep/opengrep/releases) + required: false + default: "v1.30.2" + opengrep-sha256: + description: SHA256 of the Opengrep binary for x64, given with any other opengrep-version, since its release publishes no checksum file + required: false + default: "a66aa3278457f02b287b985a45b6762aebcaba5000f2689245fd1ed86d1456c7" + opengrep-sha256-arm64: + description: SHA256 of the Opengrep binary for arm64, given with any other opengrep-version + required: false + default: "90acea5df4b733083f388d4feeb250ca802f671b33d1f222653d1e571dd2b0d8" + +runs: + using: "composite" + steps: + - name: Install Opengrep + shell: bash + env: + OPENGREP_VERSION: ${{ inputs.opengrep-version }} + OPENGREP_BINARY: opengrep_manylinux_${{ runner.arch == 'ARM64' && 'aarch64' || 'x86' }} + OPENGREP_SHA256: ${{ runner.arch == 'ARM64' && inputs.opengrep-sha256-arm64 || inputs.opengrep-sha256 }} + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + curl -fsSL -O "https://github.com/opengrep/opengrep/releases/download/${OPENGREP_VERSION}/${OPENGREP_BINARY}" + echo "${OPENGREP_SHA256} ${OPENGREP_BINARY}" | sha256sum --check --status + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + install -m 0755 "${OPENGREP_BINARY}" "${RUNNER_TEMP}/bin/opengrep" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/bin/opengrep" --version diff --git a/actions/trivy/setup/action.yml b/actions/trivy/setup/action.yml new file mode 100644 index 0000000..ab48e9d --- /dev/null +++ b/actions/trivy/setup/action.yml @@ -0,0 +1,47 @@ +name: Setup Trivy +description: | + Installs Trivy by downloading the official binary from its GitHub release and verifying its SHA256 checksum, + for a job running Trivy itself, where the Trivy scan action installs and runs it in one step. + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + trivy-version: + description: Version of Trivy to install (check latest from https://github.com/aquasecurity/trivy/releases) + required: false + default: "0.76.0" + +runs: + using: "composite" + steps: + # The same install as the Trivy scan action, which a composite action of this repository cannot call from a caller's workspace. + - name: Install Trivy + shell: bash + env: + TRIVY_VERSION: ${{ inputs.trivy-version }} + TRIVY_TARBALL: trivy_${{ inputs.trivy-version }}_Linux-${{ runner.arch == 'ARM64' && 'ARM64' || '64bit' }}.tar.gz + TRIVY_CHECKSUMS: trivy_${{ inputs.trivy-version }}_checksums.txt + run: | + # Downloaded into a directory of its own, since the repository may hold a file or a directory named like the binary. + cd "$(mktemp -d)" + BASE_URL="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}" + + echo "Downloading Trivy binary and checksums..." + curl -fsSL -O "${BASE_URL}/${TRIVY_TARBALL}" + curl -fsSL -O "${BASE_URL}/${TRIVY_CHECKSUMS}" + + echo "Verifying checksum..." + if grep " ${TRIVY_TARBALL}$" "${TRIVY_CHECKSUMS}" | sha256sum --check --status; then + echo "Verification successful!" + tar -xzf "${TRIVY_TARBALL}" trivy + chmod +x trivy + # Installed where the job owns the directory, so neither root nor sudo is needed, on a hosted runner or in a container. + mkdir -p "${RUNNER_TEMP}/bin" + mv trivy "${RUNNER_TEMP}/bin/trivy" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + export PATH="${RUNNER_TEMP}/bin:$PATH" + rm -f "${TRIVY_TARBALL}" "${TRIVY_CHECKSUMS}" + trivy version + else + echo "ERROR: Checksum verification failed!" >&2 + exit 1 + fi From f3e4b2c15de444af710aa48e8cb21bb81504100b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 26 Oct 2026 10:26:30 +0100 Subject: [PATCH 50/55] Update the action pins, tool defaults and fixture dependencies to their latest releases, with the new SHA256 checksums of mongosh, Opengrep and the Sonar scanner Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- .github/workflows/reusable-markup-lint.yml | 2 +- .github/workflows/reusable-node-quality.yml | 2 +- .../workflows/reusable-npm-publication.yml | 2 +- .../workflows/reusable-pages-deployment.yml | 2 +- .../workflows/reusable-terraform-quality.yml | 6 ++-- .istarci.yml | 2 +- actions/codeql/setup/action.yml | 2 +- actions/cosign/sign/action.yml | 2 +- actions/fossa/analyze/action.yml | 2 +- actions/goreleaser/setup/action.yml | 2 +- actions/mongodb/start/action.yml | 6 ++-- actions/opengrep/setup/action.yml | 6 ++-- actions/sonar/scan/action.yml | 6 ++-- actions/syft/generate-sbom/action.yml | 2 +- actions/terraform/setup/action.yml | 2 +- actions/tflint/setup/action.yml | 2 +- actions/trivy/scan/action.yml | 2 +- actions/trivy/setup/action.yml | 2 +- test/fixtures/dotnet/Fixture.Tests.csproj | 2 +- test/fixtures/go/module/go.mod | 2 +- test/fixtures/go/unformatted/go.mod | 2 +- test/fixtures/go/untidy/go.mod | 4 +-- test/fixtures/node/package-lock.json | 16 +++++----- test/fixtures/node/package.json | 2 +- test/fixtures/playwright/package-lock.json | 16 +++++----- test/fixtures/playwright/package.json | 2 +- test/fixtures/terraform/.terraform.lock.hcl | 31 ++++++++++--------- 28 files changed, 67 insertions(+), 64 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae8f07c..76080ce 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -976,7 +976,7 @@ jobs: FIXTURE_VALUE=extra mongodb-enabled: true task-enabled: true - terraform-version: 1.16.6 + terraform-version: 1.17.0 test-script: test:coverage working-directory: test/fixtures/node diff --git a/.github/workflows/reusable-markup-lint.yml b/.github/workflows/reusable-markup-lint.yml index 68db049..8839e3a 100644 --- a/.github/workflows/reusable-markup-lint.yml +++ b/.github/workflows/reusable-markup-lint.yml @@ -37,6 +37,6 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # The linters are pinned, so that a new release changes the verdict on a repository only when this one moves to it. - name: Lint Markdown files - run: npx --yes markdownlint-cli2@0.23.3 "**/*.md" + run: npx --yes markdownlint-cli2@0.24.0 "**/*.md" - name: Lint YAML files run: pipx run --spec yamllint==1.38.0 yamllint . diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index 3a28cfa..d360e7c 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -187,7 +187,7 @@ jobs: mkdir -p "$(dirname "$exclude")" echo "/workflow-parts/" >> "$exclude" - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node-version }} # The npm cache is keyed on the lock file, which a package without dependencies does not have. diff --git a/.github/workflows/reusable-npm-publication.yml b/.github/workflows/reusable-npm-publication.yml index ec00332..d356f99 100644 --- a/.github/workflows/reusable-npm-publication.yml +++ b/.github/workflows/reusable-npm-publication.yml @@ -88,7 +88,7 @@ jobs: path: workflow-parts persist-credentials: false - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node-version }} # Writes the .npmrc that npm publish and npm view authenticate with. diff --git a/.github/workflows/reusable-pages-deployment.yml b/.github/workflows/reusable-pages-deployment.yml index 0e1de31..85b2dd4 100644 --- a/.github/workflows/reusable-pages-deployment.yml +++ b/.github/workflows/reusable-pages-deployment.yml @@ -107,7 +107,7 @@ jobs: uses: ./workflow-parts/actions/task/setup - name: Setup Node.js if: inputs.node-version != '' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: ${{ inputs.node-version }} # The npm cache is keyed on the lock file, which a site installing its tools globally does not have. diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index a98ebf3..d16b654 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -64,14 +64,14 @@ jobs: # Checkov is a static code analysis tool for infrastructure as code (IaC) and also a software composition analysis (SCA) tool for images and open source packages - name: Run Checkov run: | - pipx run --spec checkov==3.3.29 checkov -d . \ + pipx run --spec checkov==3.3.30 checkov -d . \ --soft-fail \ --output cli --output sarif \ --output-file-path console,results.sarif continue-on-error: true # needs GitHub code security > code scanning, not available on private repos # - name: Upload SARIF file - # uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + # uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 # if: success() || failure() # with: # sarif_file: results.sarif @@ -85,7 +85,7 @@ jobs: uses: ./workflow-parts/actions/tflint/setup with: # ref. https://github.com/terraform-linters/tflint/releases - tflint-version: v0.64.0 + tflint-version: v0.65.0 - name: Initialize TFLint run: tflint --init --recursive env: diff --git a/.istarci.yml b/.istarci.yml index 046c413..8290bf5 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -12,7 +12,7 @@ runner: test-node-quality-sonar: ghcr.io/devpro/debian-node-playwright:latest test-npm-publication: ghcr.io/devpro/debian-node:latest # The Go actions need the Go toolchain, which setup-go installs on GitHub and a local run takes from the official image. - test-go-lint-govulncheck-scan: golang:1.26-trixie + test-go-lint-govulncheck-scan: golang:1.27-trixie # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] # The Sonar paths of the .NET and Node.js quality workflows and of the Sonar action run against a SonarQube of this machine, which GitHub does not have. diff --git a/actions/codeql/setup/action.yml b/actions/codeql/setup/action.yml index f706aab..13d63cf 100644 --- a/actions/codeql/setup/action.yml +++ b/actions/codeql/setup/action.yml @@ -8,7 +8,7 @@ inputs: codeql-version: description: Version of the CodeQL bundle to install, without its leading v (check latest from https://github.com/github/codeql-action/releases) required: false - default: "2.27.2" + default: "2.27.3" runs: using: "composite" diff --git a/actions/cosign/sign/action.yml b/actions/cosign/sign/action.yml index a8f5f2b..c411d22 100644 --- a/actions/cosign/sign/action.yml +++ b/actions/cosign/sign/action.yml @@ -11,7 +11,7 @@ inputs: cosign-version: description: Version of cosign (check latest from https://github.com/sigstore/cosign/releases) required: false - default: 'v3.1.3' + default: 'v3.1.4' image-name: description: Image name required: true diff --git a/actions/fossa/analyze/action.yml b/actions/fossa/analyze/action.yml index 4c97057..2e0c85e 100644 --- a/actions/fossa/analyze/action.yml +++ b/actions/fossa/analyze/action.yml @@ -9,7 +9,7 @@ inputs: fossa-version: description: Version of the FOSSA CLI to install (check latest from https://github.com/fossas/fossa-cli/releases) required: false - default: "3.20.4" + default: "3.20.7" api-key: description: FOSSA API key required: true diff --git a/actions/goreleaser/setup/action.yml b/actions/goreleaser/setup/action.yml index 88b9d7f..8e8b4b1 100644 --- a/actions/goreleaser/setup/action.yml +++ b/actions/goreleaser/setup/action.yml @@ -8,7 +8,7 @@ inputs: goreleaser-version: description: Version of GoReleaser to install (check latest from https://github.com/goreleaser/goreleaser/releases) required: false - default: "2.18.3" + default: "2.19.0" runs: using: "composite" diff --git a/actions/mongodb/start/action.yml b/actions/mongodb/start/action.yml index 16d3d50..7fd0688 100644 --- a/actions/mongodb/start/action.yml +++ b/actions/mongodb/start/action.yml @@ -13,15 +13,15 @@ inputs: mongosh-version: description: Version of mongosh (check latest from https://github.com/mongodb-js/mongosh/releases) required: false - default: "2.13.0" + default: "2.14.0" mongosh-sha256: description: SHA256 of the mongosh archive for x64, given with any other mongosh-version, since its release publishes no checksum file required: false - default: "b2089e67641a28aa621476c4d62c69f66b9a41484baba24d8a8b1f5f96e92d0b" + default: "c1c5f1a9709e91c5eb1c6861a99c305c165fa564afb465fabfd2b2334c08a23c" mongosh-sha256-arm64: description: SHA256 of the mongosh archive for arm64, given with any other mongosh-version required: false - default: "a124ec6680c70ceabc61696bbd110f0da05d7c52524ed402ae9511b5aec7fe32" + default: "3c6a3631667f9caf0a7b5dfe87e433f973f0a82f2cf04130cd52115076e945e4" port: description: Port the server listens on required: false diff --git a/actions/opengrep/setup/action.yml b/actions/opengrep/setup/action.yml index 0aebcd0..3294030 100644 --- a/actions/opengrep/setup/action.yml +++ b/actions/opengrep/setup/action.yml @@ -8,15 +8,15 @@ inputs: opengrep-version: description: Version of Opengrep to install (check latest from https://github.com/opengrep/opengrep/releases) required: false - default: "v1.30.2" + default: "v1.32.0" opengrep-sha256: description: SHA256 of the Opengrep binary for x64, given with any other opengrep-version, since its release publishes no checksum file required: false - default: "a66aa3278457f02b287b985a45b6762aebcaba5000f2689245fd1ed86d1456c7" + default: "7360d60174880bd9214f2dfbe439b7bce82b7a3d44cfe945ba833c85cc8bda75" opengrep-sha256-arm64: description: SHA256 of the Opengrep binary for arm64, given with any other opengrep-version required: false - default: "90acea5df4b733083f388d4feeb250ca802f671b33d1f222653d1e571dd2b0d8" + default: "dc6229319c0d68948733053786a5614873947e5ec1d4669cc0e94ae9038cb99d" runs: using: "composite" diff --git a/actions/sonar/scan/action.yml b/actions/sonar/scan/action.yml index 112b43d..7eb3f4c 100644 --- a/actions/sonar/scan/action.yml +++ b/actions/sonar/scan/action.yml @@ -9,15 +9,15 @@ inputs: scanner-version: description: Version of the Sonar scanner CLI (check latest from https://github.com/SonarSource/sonar-scanner-cli/releases) required: false - default: "8.1.0.6389" + default: "8.2.0.6438" scanner-sha256: description: SHA256 of the scanner archive for x64, given with any other scanner-version required: false - default: "bb8f709f9cb73352f8d1260a3b3c506c0f41146754bc630762c126d795499d0b" + default: "83257bf97a475d626b8ba2b214aeeb37334ca84db1b3e3be5105783e4facb053" scanner-sha256-arm64: description: SHA256 of the scanner archive for arm64, given with any other scanner-version required: false - default: "5e1c9328f4e261838de778c9e586ee608cca45ff7f0538108642219214628ba5" + default: "3add94b481f1d541897b8d667f4c0e86bc7855bd3eaeb1a7c9ee5f162b909e6e" coverage-paths: description: lcov reports of the coverage, relative to the working directory, separated by commas, coverage/lcov.info when empty and the project has no sonar-project.properties required: false diff --git a/actions/syft/generate-sbom/action.yml b/actions/syft/generate-sbom/action.yml index a90d70a..c8cfc4c 100644 --- a/actions/syft/generate-sbom/action.yml +++ b/actions/syft/generate-sbom/action.yml @@ -9,7 +9,7 @@ inputs: syft-version: description: Version of Syft to install (check latest from https://github.com/anchore/syft/releases) required: false - default: "1.54.1" + default: "1.55.0" target-image: description: Container image reference to scan (mutually exclusive with target-path) required: false diff --git a/actions/terraform/setup/action.yml b/actions/terraform/setup/action.yml index da3d4a6..79ae72a 100644 --- a/actions/terraform/setup/action.yml +++ b/actions/terraform/setup/action.yml @@ -8,7 +8,7 @@ inputs: terraform-version: description: Version of Terraform to install (check latest from https://releases.hashicorp.com/terraform/) required: false - default: "1.16.6" + default: "1.17.0" runs: using: "composite" diff --git a/actions/tflint/setup/action.yml b/actions/tflint/setup/action.yml index c6187e0..bf2d218 100644 --- a/actions/tflint/setup/action.yml +++ b/actions/tflint/setup/action.yml @@ -8,7 +8,7 @@ inputs: tflint-version: description: Version of TFLint to install (check latest from https://github.com/terraform-linters/tflint/releases) required: false - default: "v0.64.0" + default: "v0.65.0" runs: using: "composite" diff --git a/actions/trivy/scan/action.yml b/actions/trivy/scan/action.yml index 5d7e3b2..5a36c52 100644 --- a/actions/trivy/scan/action.yml +++ b/actions/trivy/scan/action.yml @@ -9,7 +9,7 @@ inputs: trivy-version: description: Version of Trivy to install (check latest from https://github.com/aquasecurity/trivy/releases) required: false - default: "0.76.0" + default: "0.76.1" scan-type: description: What to scan, "image" (container image reference) or "config" (misconfiguration/IaC scan of a directory) required: false diff --git a/actions/trivy/setup/action.yml b/actions/trivy/setup/action.yml index ab48e9d..64fac75 100644 --- a/actions/trivy/setup/action.yml +++ b/actions/trivy/setup/action.yml @@ -8,7 +8,7 @@ inputs: trivy-version: description: Version of Trivy to install (check latest from https://github.com/aquasecurity/trivy/releases) required: false - default: "0.76.0" + default: "0.76.1" runs: using: "composite" diff --git a/test/fixtures/dotnet/Fixture.Tests.csproj b/test/fixtures/dotnet/Fixture.Tests.csproj index c7e84aa..0c676b0 100644 --- a/test/fixtures/dotnet/Fixture.Tests.csproj +++ b/test/fixtures/dotnet/Fixture.Tests.csproj @@ -10,7 +10,7 @@ - + diff --git a/test/fixtures/go/module/go.mod b/test/fixtures/go/module/go.mod index 360e099..50a0545 100644 --- a/test/fixtures/go/module/go.mod +++ b/test/fixtures/go/module/go.mod @@ -1,3 +1,3 @@ module example.com/fixture -go 1.26 +go 1.27 diff --git a/test/fixtures/go/unformatted/go.mod b/test/fixtures/go/unformatted/go.mod index 7d2ded8..25588eb 100644 --- a/test/fixtures/go/unformatted/go.mod +++ b/test/fixtures/go/unformatted/go.mod @@ -1,3 +1,3 @@ module example.com/unformatted -go 1.26 +go 1.27 diff --git a/test/fixtures/go/untidy/go.mod b/test/fixtures/go/untidy/go.mod index 210e414..b662c07 100644 --- a/test/fixtures/go/untidy/go.mod +++ b/test/fixtures/go/untidy/go.mod @@ -1,5 +1,5 @@ module example.com/untidy -go 1.26 +go 1.27 -require golang.org/x/text v0.31.0 +require golang.org/x/text v0.43.0 diff --git a/test/fixtures/node/package-lock.json b/test/fixtures/node/package-lock.json index 51adacd..04becae 100644 --- a/test/fixtures/node/package-lock.json +++ b/test/fixtures/node/package-lock.json @@ -6,16 +6,16 @@ "": { "name": "node-fixture", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.1" } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -25,9 +25,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/test/fixtures/node/package.json b/test/fixtures/node/package.json index 1f0ef41..f20561e 100644 --- a/test/fixtures/node/package.json +++ b/test/fixtures/node/package.json @@ -10,6 +10,6 @@ "test:coverage": "node record.js test:coverage && mkdir -p coverage && node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=coverage/lcov.info" }, "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.1" } } diff --git a/test/fixtures/playwright/package-lock.json b/test/fixtures/playwright/package-lock.json index 0748bb5..b9ff5f0 100644 --- a/test/fixtures/playwright/package-lock.json +++ b/test/fixtures/playwright/package-lock.json @@ -6,16 +6,16 @@ "": { "name": "playwright-fixture", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.1" } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -25,9 +25,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/test/fixtures/playwright/package.json b/test/fixtures/playwright/package.json index e7c34c4..eb96274 100644 --- a/test/fixtures/playwright/package.json +++ b/test/fixtures/playwright/package.json @@ -2,6 +2,6 @@ "name": "playwright-fixture", "private": true, "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.1" } } diff --git a/test/fixtures/terraform/.terraform.lock.hcl b/test/fixtures/terraform/.terraform.lock.hcl index be7a6ca..4f0ac51 100644 --- a/test/fixtures/terraform/.terraform.lock.hcl +++ b/test/fixtures/terraform/.terraform.lock.hcl @@ -2,22 +2,25 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/local" { - version = "2.9.0" + version = "2.9.1" constraints = "~> 2.5" hashes = [ - "h1:9rBZCMNpxKwMlRbWH2QpwD3kqUCAejdOZQ/aiiDObXQ=", - "zh:0baa4566cf77f1ff52f4293d1c8536202dd23edc197c3196413a28343c3ac3a0", - "zh:16b5559c3c07088ddad11a9bb9e9c0799999363c2958e9a5be2bcbbf2cd9ca64", - "zh:197c79015a10d1cce904a8ea722cbc750c42aeae2da53f44a6a0751d9fd1aa90", - "zh:29d0b03e5343a80677ebfeb2e2c31cbe4b1f65e736e53417454a4277fec2544c", - "zh:4896bfa6cf1d2fd562b47ef2e87f47862ae92a04f8ad5d764380f0c6653473b8", - "zh:531f8529cbca49f681883e57761a05a8398afaef6d1ab0d205d26bf12f4428e8", - "zh:6aaf5011d83161c86d2bfb80c0923ec934e578288758da2f37acb7aec129004b", - "zh:7430275253d3d3c40aa6179e0ec0d63212874dbbc06c5a51b9d07ec590f9756c", + "h1:OZGJN0LSSat5QIxZPxDXtVr4XpHb2oG7cVKJhSqBFIE=", + "h1:bek0XHcE8X713/1PR+sMKZFwLnamznJdYbORghbFRn0=", + "h1:qGLHCuYSus+uHNnoEL4SuJqOs5yrNOcB7gnuHoVqizo=", + "h1:tX/GU6iR6pP5XMj3zo22mIsZEIW2GUZ9GxfVdo2KjoI=", + "zh:25606c7a5e308144fb627f6e31611bb52ff72bb9ae2d27af39673ab1a6b3c1bf", + "zh:2568c4ef4dab31821f6f7040af0d1a2aa2b9455b8d9cc546598b791b8ada34cd", + "zh:25ac210f136042047975896e5e11fe6012425301c826d6b7ce22a49f96a1e0e8", + "zh:55d3a7bf01eced8e1f259b548020ef1221c5687e0fe6b5b30f81ad0b4121ff12", + "zh:6168d6934777b853c57815baa18a54101c604a6f1124ca47cd23d016c6f2f1e5", + "zh:63c0aa17c8373f761123376226debffd1f2259901ff259d4547c520def9a6787", + "zh:6d1f1572db6c85bd6d6b1b1deef247084e189816875f67e6bcc5d4736794e0a9", "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", - "zh:be17dc611e95e26cdf6cad79dfccf1064f0e32032a2efeb939a9bbe7fb1cbfe9", - "zh:f0e3b0aa644202e1d79d2000dca91f6019425da71e9800fa23f27e51c034f195", - "zh:f62bae4519e4ead49182ddc8afe8cf61e2a4c3ba3973b0fbba967736a2696aa3", - "zh:fcafa360a5b0b96244f26f4e3a6d642b716a376557142c2442ff2fb12d11da18", + "zh:a8ea1c155bd5e0d695a28d3c0e2f542de8844e1791ea021f015a3f08e1c059e1", + "zh:ccd790f97269509ade0574ef63c955d3943cb1d812c59177bd85ce4c1d17ca0f", + "zh:dcfc17bb666e659a9daa9ff52fd8f6ec9ea519868726015e645955c372296332", + "zh:eb83abbfa7f52cba609b48cbb9a6a5cab07780a672b2020fc2ba190e699a2516", + "zh:eedffca70074ae153790fb23c4ace8d1637a60ceba4e3c42c8eaeda7c1f9d10f", ] } From b385f135692953ed2cb7a03ca73d3d77c5ca09a5 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 26 Oct 2026 10:47:35 +0100 Subject: [PATCH 51/55] Run the Go job of a local run on the SUSE Go image, which carries the fixes the official Debian based one lacks Co-Authored-By: Claude Opus 5.5 --- .istarci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.istarci.yml b/.istarci.yml index 8290bf5..301417e 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -11,8 +11,8 @@ runner: test-node-quality-pnpm: ghcr.io/devpro/debian-node:latest test-node-quality-sonar: ghcr.io/devpro/debian-node-playwright:latest test-npm-publication: ghcr.io/devpro/debian-node:latest - # The Go actions need the Go toolchain, which setup-go installs on GitHub and a local run takes from the official image. - test-go-lint-govulncheck-scan: golang:1.27-trixie + # The Go actions need the Go toolchain, which setup-go installs on GitHub and a local run takes from the SUSE image, rebuilt with the Debian fixes the official one still lacks at times. + test-go-lint-govulncheck-scan: registry.suse.com/bci/golang:1.27 # The container scan workflow builds an image through the host Docker daemon, as a hosted runner does, and Cosign reads a digest with the Docker CLI. docker: ["test-container-scan-on-base*", "test-cosign-sign*", "test-container-build-push*"] # The Sonar paths of the .NET and Node.js quality workflows and of the Sonar action run against a SonarQube of this machine, which GitHub does not have. From 9fd8cfe6bd9b2e2b0b6f0b0f82c8d08398a27248 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 26 Oct 2026 11:13:59 +0100 Subject: [PATCH 52/55] Add skill adopt workflow parts --- .claude/skills/adopt-workflow-parts/SKILL.md | 115 +++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 .claude/skills/adopt-workflow-parts/SKILL.md diff --git a/.claude/skills/adopt-workflow-parts/SKILL.md b/.claude/skills/adopt-workflow-parts/SKILL.md new file mode 100644 index 0000000..a66ebae --- /dev/null +++ b/.claude/skills/adopt-workflow-parts/SKILL.md @@ -0,0 +1,115 @@ +--- +name: adopt-workflow-parts +description: > + Move a repository's GitHub workflows onto the github-workflow-parts catalog, change the catalog when a repository needs a part it lacks, + and bump consumers to a new catalog commit. + Use when adopting, updating or bumping workflow parts in any repository, or when an IstarCI run of a consumer fails. +--- + +# Adopt workflow parts + +## Goal + +The catalog is a high quality set of GitHub parts that pushes best practices into every repository and removes most of their pipeline code. +A consumer workflow is a few `uses:` of reusable workflows with inputs, no hand written tool installs, no action referenced by a version tag. + +## Procedure + +### Catalog change + +1. Start from the closest existing part and verify every claim (a CLI flag, a config file, a release) before stating it. +2. Propose the design: steps, inputs with defaults, and what the change adds or removes. +3. Wait for approval before any edit. +4. Edit, run `test/run_static_checks.sh`, commit, `istarci wait`. +5. The user pushes; consumers reference the pushed SHA only. + +### Consumer adoption + +1. `git rev-parse --abbrev-ref HEAD`: commit on the current branch, unless it is `main`, where a feature branch comes first. +2. Strip any UTF-8 byte order mark from tracked files and set `.editorconfig` `charset = utf-8-bom` to `utf-8`, in a commit of its own. +3. Replace `.github/workflows` jobs with the reusable workflows, every `uses:` and `workflow-parts-version` on the same full SHA. +4. Change one repository first, show its diff, wait, then do the others. +5. Commit, then `istarci wait`, and fix what fails before moving on. + +### Bump pass + +1. Replace the old SHA with the new one in every `uses:` and `workflow-parts-version` of each repository. +2. Commit "Use workflow parts *short sha*, ..." stating what the new commit brings, then `istarci wait` repository by repository. +3. A failure from a new check (a vulnerability found by an image scan) is fixed in its own commit, in the same pass. + +## Conventions + +### Reusable workflows + +- A few broad steps driven by conventions: + npm scripts `lint`, `format:check`, `typecheck`, `build`, `test` run with `--if-present`, a Pages build with `npm ci` then `npm run build`. +- Optional parts behind a flag or an input with a default (`mongodb-enabled`, `mongodb-version`, `severity`), + and a single optional `custom-tests` step. +- Never one step running whatever command a caller gives. +- Every shell input reaches the script through `env:`, never `${{ }}` inside `run:`, which is a script injection. +- Tool configuration files of the repository are respected (`sonar-project.properties`, `.trivyignore.yaml`). +- The checkout of the catalog itself is added to `.git/info/exclude`, so linters and scanners of the consumer skip it. +- No test in a publication workflow (`pkg.yml`): quality runs in CI, publication only builds and ships. +- `reusable-dotnet-quality.yml` is an old example, improved alongside rather than copied. + +### Supply chain + +Safety before convenience, and fewer dependencies over more. + +- First-party actions (`actions/*`, `github/*`) pinned by full SHA with a `# vX.Y.Z` inline comment with two spaces before (yamllint). +- No third-party action: a tool is a release binary checked against its SHA256 in a `setup` action, or the runner's OS packages. +- No third-party container image; `ghcr.io/devpro/*` and Docker Hub `devprofr/*` are first party. +- No Dependabot: versions are bumped by hand after checking the release exists. +- Lock files with hashes: `npm ci`, `pip install --require-hashes`, never an unpinned global install. +- A vulnerable image with no fixed release is replaced by a first-party one (`alpine-node` over `node`, an in-suite mailbox over Mailpit) + rather than ignored. +- A Trivy ignore, with a reason and an expiry under a month, is the last resort, for something run unprivileged in tests only. +- An npm `overrides` entry is a last resort too: + only when no release of the parent package, `latest` or `next`, fixes the dependency, and it is reverted once one does. +- Secrets stay secrets, even a low value one such as an IndexNow key in a public repository. + +### Versions + +- Node.js 24 by default, a library tested on 22, 24 and 26. +- MongoDB 9.0 in Docker Compose files, a single-node replica set `rs0` when the code uses transactions. + +## Boundaries + +- Azure DevOps and GitLab pipelines are never removed or emptied: the same repositories run on several CI systems. + GitLab gets its own session; only a minimal path fix keeping it working is allowed here. +- Deliberately vulnerable images for security workshops (container-images `tinyfilemanager`, `nextportal`) are left untouched, + neither scanned nor published. +- Commit only when asked, never push, never commit on `main`. +- YAML and Markdown linters are never run by an agent: CI runs them. + +## IstarCI + +- The daemon runs every commit by itself: after a commit, `istarci wait` only. +- `istarci run ` retries a run that must run again (daemon restart, change to `~/.istarci/config.yml`), never right after a commit. +- A command over the 600 second tool limit is moved to the background, against the foreground rule: + `timeout 590 istarci wait` then call it again while the run is going. +- `.istarci.yml` of a consumer sets the runner image per job (`ghcr.io/devpro/debian-node-playwright` for browser tests), + the jobs needing Docker, + and `skipJobs` for jobs that must never run on the developer machine (kind's privileged node, a QEMU registration in the kernel). +- The global ignores of image scans live in `~/.istarci/config.yml`, each with a reason and an expiry. + +## Gotchas + +- npm keeps the lock entry of a parent package when an override is added, and may even drop the child: + delete the lock entries of the parent and of the child, then `npm install`, and check `npm ls --all` says `overridden`. +- Use the Node.js of `~/.nvm/versions/node/v24.22.1/bin`: the shell `PATH` may resolve an older, vulnerable one. +- A file starting with a byte order mark breaks some tools, + and `dotnet format` fails every C# file when `.editorconfig` asks for `utf-8-bom` and the files have none. +- `build-push` uses the Docker daemon BuildKit (`--builder default`), with one tag per platform merged by `docker buildx imagetools create`, + as a `docker-container` builder cannot see images of the daemon. +- Trivy scans Java dependencies with `--offline-scan`, or it calls Maven Central for each one. +- CodeQL needs more memory than the default on large code bases, and its .NET extractor needs ICU. +- Python scanners reading non-ASCII sources need `PYTHONUTF8=1` in a container whose locale is not UTF-8. +- Playwright tests are found through `node_modules/.bin/playwright` of the root only, not of a workspace. +- A Sonar scan without `sonar-host-url` targets SonarCloud. + +## Commits + +- One sentence saying why, in the writing style of `AGENTS.md`: no em dash, no second person, the why timeless. +- Script files `snake_case`, committed with the executable bit (`git update-index --chmod=+x`). +- The final report lists each repository, branch, commit and run result, and what is left to the user (pushes, secrets, a first publication run). From e3e6cd9ef8eb10b3b65841fbe00aa2336e848975 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 26 Oct 2026 11:34:28 +0100 Subject: [PATCH 53/55] Install qemu-user-binfmt in the QEMU setup action where qemu-user-static is only a virtual package, as on Ubuntu 26.04 behind ubuntu-latest Co-Authored-By: Claude Opus 5.5 --- actions/qemu/setup/action.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/actions/qemu/setup/action.yml b/actions/qemu/setup/action.yml index f10390d..5fdc892 100644 --- a/actions/qemu/setup/action.yml +++ b/actions/qemu/setup/action.yml @@ -1,7 +1,7 @@ name: Setup QEMU description: | Registers QEMU user emulation for other platforms than the runner's own, so that Docker builds and runs their images, - from the qemu-user-static package of the Ubuntu archive, which apt verifies against the keys of the runner's own distribution + from the qemu-user-static package of the Ubuntu archive (qemu-user-binfmt from Ubuntu 26.04), which apt verifies against the keys of the runner's own distribution (replacement for docker/setup-qemu-action and its tonistiigi/binfmt image, to run no third party image with privileges). Linux runners on Ubuntu only (for example ubuntu-latest), with sudo, since the registration is made in the kernel. @@ -18,7 +18,12 @@ runs: shell: bash run: | sudo apt-get update -qq - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-user-static + # From QEMU 9.1 (Ubuntu 26.04), qemu-user ships the static binaries and qemu-user-static is only a virtual package, which apt cannot install. + package=qemu-user-static + if apt-cache show --no-all-versions qemu-user-binfmt | grep -q '^Provides:.*\bqemu-user-static\b'; then + package=qemu-user-binfmt + fi + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends "$package" # The package ships its registrations in binfmt.d, which systemd-binfmt loads into the kernel. sudo /usr/lib/systemd/systemd-binfmt - name: Check the emulation of each platform From 83f8489dad74232bc6e3e56e3a1d3be9de4ead8a Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 26 Oct 2026 11:52:48 +0100 Subject: [PATCH 54/55] Pass the Sonar organization to the Sonar test jobs, for them to run against SonarCloud, and name the .NET and properties file fixture projects after the repository --- .github/workflows/ci.yml | 13 ++++++++----- .../sonar-properties/sonar-project.properties | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76080ce..5624dc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -750,7 +750,7 @@ jobs: # The fixture holds a test that fails, which only the filter keeps the job from running, its quotes being read as shell quotes rather than passed on. dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace 'Fixture.Tests.LeftOut*' - # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. + # Runs where a Sonar server is set in SONAR_HOST_URL, as IstarCI provides a SonarQube locally, and is skipped otherwise. test-dotnet-quality-sonar: name: "Test / .NET quality reusable workflow with Sonar" if: vars.SONAR_HOST_URL != '' @@ -760,8 +760,9 @@ jobs: dotnet-test-args: --report-xunit-trx --coverage --coverage-output-format cobertura --filter-not-namespace 'Fixture.Tests.LeftOut*' sonar-enabled: true sonar-host-url: ${{ vars.SONAR_HOST_URL }} - sonar-project-key: github-workflow-parts-fixture - sonar-project-name: GitHub workflow parts fixture + sonar-organization: ${{ vars.SONAR_ORGANIZATION }} + sonar-project-key: github-workflow-parts-dotnet-fixture + sonar-project-name: GitHub workflow parts .NET fixture secrets: sonar-token: ${{ secrets.SONAR_TOKEN }} @@ -989,7 +990,7 @@ jobs: pnpm-version: "12" working-directory: test/fixtures/pnpm - # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. + # Runs where a Sonar server is set in SONAR_HOST_URL, as IstarCI provides a SonarQube locally, and is skipped otherwise. test-node-quality-sonar: name: "Test / Node.js quality reusable workflow with Sonar" if: vars.SONAR_HOST_URL != '' @@ -997,6 +998,7 @@ jobs: with: sonar-enabled: true sonar-host-url: ${{ vars.SONAR_HOST_URL }} + sonar-organization: ${{ vars.SONAR_ORGANIZATION }} sonar-project-key: github-workflow-parts-node-fixture sonar-project-name: GitHub workflow parts Node.js fixture test-script: test:coverage @@ -1004,7 +1006,7 @@ jobs: secrets: sonar-token: ${{ secrets.SONAR_TOKEN }} - # Runs where a SonarQube is at hand, as IstarCI provides one locally, and is skipped on GitHub, which has none for this repository. + # Runs where a Sonar server is set in SONAR_HOST_URL, as IstarCI provides a SonarQube locally, and is skipped otherwise. test-sonar-scan-properties: name: "Test / Sonar scan action with a sonar-project.properties" if: vars.SONAR_HOST_URL != '' @@ -1027,6 +1029,7 @@ jobs: SONAR_SCANNER_JSON_PARAMS: '{"sonar.scanner.keepReport": "true"}' with: host-url: ${{ vars.SONAR_HOST_URL }} + organization: ${{ vars.SONAR_ORGANIZATION }} token: ${{ secrets.SONAR_TOKEN }} working-directory: test/fixtures/sonar-properties # The token of the run may only send an analysis, so the report the scanner kept tells what it indexed. diff --git a/test/fixtures/sonar-properties/sonar-project.properties b/test/fixtures/sonar-properties/sonar-project.properties index af52792..6ec063a 100644 --- a/test/fixtures/sonar-properties/sonar-project.properties +++ b/test/fixtures/sonar-properties/sonar-project.properties @@ -1,4 +1,5 @@ # The configuration the Sonar action reads, with no input but the connection. sonar.projectKey=github-workflow-parts-sonar-properties +sonar.projectName=GitHub workflow parts properties file sonar.sources=. sonar.exclusions=excluded/** From c2bff212284a3f7cc35bf4a6541ca10c28c8a6c9 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 26 Oct 2026 12:06:31 +0100 Subject: [PATCH 55/55] Set the Sonar organization to empty for the local SonarQube of IstarCI, which then names no variable left unset Co-Authored-By: Claude Opus 5.5 --- .istarci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.istarci.yml b/.istarci.yml index 301417e..0df94be 100644 --- a/.istarci.yml +++ b/.istarci.yml @@ -21,3 +21,7 @@ runner: workflow: # Registering an emulator changes the kernel of the machine, which a local run leaves alone. skipJobs: [test-qemu-setup] + +vars: + # The local SonarQube has no organization, which only SonarCloud needs. + SONAR_ORGANIZATION: ""