From 52b861579bf9ebe7175d9fc82bfb298c98c4d10b Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Mon, 26 Oct 2026 23:31:41 +0100 Subject: [PATCH 1/5] Lint the shell scripts of the calling repository with ShellCheck in the markup lint workflow ShellCheck is pinned and its archive verified against a SHA256 kept in the workflow, since its releases publish no checksum file. A script without the .sh extension is found by its shebang. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/reusable-markup-lint.yml | 23 ++++++++++++++++++++++ README.md | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/reusable-markup-lint.yml b/.github/workflows/reusable-markup-lint.yml index 8839e3a..84077f5 100644 --- a/.github/workflows/reusable-markup-lint.yml +++ b/.github/workflows/reusable-markup-lint.yml @@ -40,3 +40,26 @@ jobs: run: npx --yes markdownlint-cli2@0.24.0 "**/*.md" - name: Lint YAML files run: pipx run --spec yamllint==1.38.0 yamllint . + - name: Lint shell scripts + env: + SHELLCHECK_ARCH: ${{ runner.arch == 'ARM64' && 'aarch64' || 'x86_64' }} + run: | + # ShellCheck publishes no checksum file, so the SHA256 of each archive is pinned here, as GitHub reports it for the release assets. + shellcheck_version="v0.11.0" + declare -A shellcheck_sha256=( + [x86_64]="b7af85e41cc99489dcc21d66c6d5f3685138f06d34651e6d34b42ec6d54fe6f6" + [aarch64]="68a8133197a50beb8803f8d42f9908d1af1c5540d4bb05fdfca8c1fa47decefc" + ) + archive="${RUNNER_TEMP}/shellcheck.tar.gz" + curl -fsSL --proto "=https" --tlsv1.2 -o "$archive" "https://github.com/koalaman/shellcheck/releases/download/${shellcheck_version}/shellcheck-${shellcheck_version}.linux.${SHELLCHECK_ARCH}.tar.gz" + echo "${shellcheck_sha256[$SHELLCHECK_ARCH]} ${archive}" | sha256sum --check --strict + tar -xzf "$archive" -C "$RUNNER_TEMP" + shellcheck="${RUNNER_TEMP}/shellcheck-${shellcheck_version}/shellcheck" + # A script without the .sh extension, such as a Git hook, is found by its shebang. + mapfile -t scripts < <(git ls-files -z | xargs -0 -r awk 'FNR == 1 { if (FILENAME ~ /\.sh$/ || /^#!.*[\/ ](ba)?sh([ \t]|$)/) print FILENAME; nextfile }') + if [[ ${#scripts[@]} -eq 0 ]]; then + echo "No shell script to lint" + exit 0 + fi + "$shellcheck" --version + "$shellcheck" "${scripts[@]}" diff --git a/README.md b/README.md index 282eea4..4046287 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ GitHub Pages: - [Build a site and deploy it](.github/workflows/reusable-pages-deployment.yml) -Markup (Markdown, YAML): +Markup (Markdown, YAML, shell scripts): - [Ensure the quality of the content](.github/workflows/reusable-markup-lint.yml) From 6e101906191c6afe4198b97abeee19d2a7c49788 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 27 Oct 2026 12:50:59 +0100 Subject: [PATCH 2/5] Pass a repository's SonarQube.Analysis.xml to the .NET scanner, so that its analysis settings are versioned with its code The scanner for .NET never reads a sonar-project.properties, and the begin step held no way to add a setting. The new sonar-settings-file input reaches the scanner with /s:, and the Sonar test of the .NET fixture loads one, since a missing or invalid file stops the analysis. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 + .github/workflows/reusable-dotnet-quality.yml | 6 ++++++ actions/dotnet/build-test-sonar/action.yml | 9 +++++++++ test/fixtures/dotnet/SonarQube.Analysis.xml | 8 ++++++++ 4 files changed, 24 insertions(+) create mode 100644 test/fixtures/dotnet/SonarQube.Analysis.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5624dc0..2b3d14e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -763,6 +763,7 @@ jobs: sonar-organization: ${{ vars.SONAR_ORGANIZATION }} sonar-project-key: github-workflow-parts-dotnet-fixture sonar-project-name: GitHub workflow parts .NET fixture + sonar-settings-file: SonarQube.Analysis.xml secrets: sonar-token: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 14cec26..3903009 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -93,6 +93,11 @@ on: type: string required: false default: "" + sonar-settings-file: + description: SonarQube.Analysis.xml file of the repository, relative to the working directory, holding analysis settings such as issue exclusions + 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 @@ -197,6 +202,7 @@ jobs: sonar-organization: ${{ inputs.sonar-organization }} sonar-project-key: ${{ inputs.sonar-project-key }} sonar-project-name: ${{ inputs.sonar-project-name }} + sonar-settings-file: ${{ inputs.sonar-settings-file }} 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. diff --git a/actions/dotnet/build-test-sonar/action.yml b/actions/dotnet/build-test-sonar/action.yml index 0c90527..69b5bf0 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: "" + sonar-settings-file: + description: SonarQube.Analysis.xml file of the repository, relative to the working directory, holding analysis settings such as issue exclusions + 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 @@ -99,6 +103,10 @@ runs: if [ -n "$SONAR_PROJECT_NAME" ]; then args+=(/n:"$SONAR_PROJECT_NAME") fi + # The scanner for .NET never reads a sonar-project.properties, so a settings file passed with /s: is how a repository versions its own settings. + if [ -n "$SONAR_SETTINGS_FILE" ]; then + args+=(/s:"$SONAR_SETTINGS_FILE") + fi "$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" begin "${args[@]}" shell: bash working-directory: ${{ inputs.working-directory }} @@ -110,6 +118,7 @@ runs: SONAR_ORGANIZATION: ${{ inputs.sonar-organization }} SONAR_PROJECT_KEY: ${{ inputs.sonar-project-key }} SONAR_PROJECT_NAME: ${{ inputs.sonar-project-name }} + SONAR_SETTINGS_FILE: ${{ inputs.sonar-settings-file }} SONAR_TOKEN: ${{ inputs.sonar-token }} - name: Build .NET solution run: dotnet build --no-restore --configuration Debug diff --git a/test/fixtures/dotnet/SonarQube.Analysis.xml b/test/fixtures/dotnet/SonarQube.Analysis.xml new file mode 100644 index 0000000..5d2b9e3 --- /dev/null +++ b/test/fixtures/dotnet/SonarQube.Analysis.xml @@ -0,0 +1,8 @@ + + + + e1 + csharpsquid:S2699 + **/LeftOutTests.cs + From 1c7f098dd14618eeb1a5080b60eaf07c3a12c3c4 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 27 Oct 2026 12:54:46 +0100 Subject: [PATCH 3/5] Pass the Sonar settings file by absolute path, since the scanner resolves a relative one against its own .sonarqube folder Co-Authored-By: Claude Opus 5.5 --- actions/dotnet/build-test-sonar/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/actions/dotnet/build-test-sonar/action.yml b/actions/dotnet/build-test-sonar/action.yml index 69b5bf0..8bd3eed 100644 --- a/actions/dotnet/build-test-sonar/action.yml +++ b/actions/dotnet/build-test-sonar/action.yml @@ -104,8 +104,9 @@ runs: args+=(/n:"$SONAR_PROJECT_NAME") fi # The scanner for .NET never reads a sonar-project.properties, so a settings file passed with /s: is how a repository versions its own settings. + # The path is made absolute, since the scanner resolves a relative one against its own .sonarqube folder. if [ -n "$SONAR_SETTINGS_FILE" ]; then - args+=(/s:"$SONAR_SETTINGS_FILE") + args+=(/s:"$(realpath "$SONAR_SETTINGS_FILE")") fi "$GITHUB_WORKSPACE/.sonar/scanner/dotnet-sonarscanner" begin "${args[@]}" shell: bash From 84d4150c54b53b35f8aa0a62d1d918276cb49dd5 Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Tue, 27 Oct 2026 15:49:15 +0100 Subject: [PATCH 4/5] Check out without persisting the token in .git/config, since no later step talks to the remote with it Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 58 +++++++++++++++++++ .../reusable-container-publication.yml | 10 ++++ .github/workflows/reusable-container-scan.yml | 3 + .github/workflows/reusable-dotnet-quality.yml | 2 + .github/workflows/reusable-git-check.yml | 2 + .github/workflows/reusable-markup-lint.yml | 2 + .github/workflows/reusable-node-quality.yml | 1 + .../workflows/reusable-npm-publication.yml | 2 + .../workflows/reusable-pages-deployment.yml | 2 + .../reusable-terraform-deployment.yml | 3 + .../workflows/reusable-terraform-quality.yml | 3 + 11 files changed, 88 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b3d14e..0df302e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Run actionlint, shellcheck and the conventions of this repository run: test/run_static_checks.sh @@ -36,6 +38,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install terraform uses: ./actions/terraform/setup - name: Verify terraform is installed @@ -50,6 +54,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup TFLint uses: ./actions/tflint/setup - name: Verify tflint is installed @@ -64,6 +70,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install terraform uses: ./actions/terraform/setup with: @@ -108,6 +116,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install a terraform release that does not exist id: terraform-missing-release continue-on-error: true @@ -179,6 +189,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Helm uses: ./actions/helm/setup - name: Verify helm is installed @@ -193,6 +205,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Helm uses: ./actions/helm/setup - name: Setup kube-linter @@ -222,6 +236,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Opengrep uses: ./actions/opengrep/setup - name: Setup Trivy @@ -246,6 +262,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Helm uses: ./actions/helm/setup - name: Setup chart-testing @@ -262,6 +280,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup GoReleaser uses: ./actions/goreleaser/setup - name: Verify goreleaser is installed @@ -276,6 +296,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Go uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -317,6 +339,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # 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: | @@ -360,6 +384,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Task uses: ./actions/task/setup - name: Verify task is installed @@ -374,6 +400,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # 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 @@ -408,6 +436,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Package the library fixture uses: ./actions/dotnet/pack-push with: @@ -428,6 +458,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup QEMU uses: ./actions/qemu/setup with: @@ -467,6 +499,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Build and push the base image under a tag id: base uses: ./actions/container/build-push @@ -510,6 +544,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Build and push the base image natively id: native uses: ./actions/container/build-push @@ -538,6 +574,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Terraform uses: ./actions/terraform/setup - name: Setup TFLint @@ -590,6 +628,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Start MongoDB uses: ./actions/mongodb/start - name: Verify MongoDB answers, on the latest release by default @@ -604,6 +644,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Start MongoDB 8.0 uses: ./actions/mongodb/start with: @@ -619,6 +661,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Start MongoDB uses: ./actions/mongodb/start with: @@ -635,6 +679,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Generate SBOM for this repository uses: ./actions/syft/generate-sbom with: @@ -669,6 +715,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Scan a real image with Trivy uses: ./actions/trivy/scan with: @@ -774,6 +822,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install .NET & lint uses: ./actions/dotnet/install-lint-restore with: @@ -812,6 +862,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # 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 @@ -880,6 +932,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Publish a version the registry already has id: published uses: ./actions/npm/publish @@ -920,6 +974,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the dependencies working-directory: test/fixtures/playwright run: npm ci @@ -1016,6 +1072,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # As the Node.js quality workflow does with its workflow parts. - name: Leave a folder of the fixture out of git run: | diff --git a/.github/workflows/reusable-container-publication.yml b/.github/workflows/reusable-container-publication.yml index e88d581..cbf7d67 100644 --- a/.github/workflows/reusable-container-publication.yml +++ b/.github/workflows/reusable-container-publication.yml @@ -110,6 +110,8 @@ jobs: fi - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -117,6 +119,7 @@ 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 + persist-credentials: false - name: Login to container registry env: REGISTRY: ${{ inputs.container-registry }} @@ -163,6 +166,8 @@ jobs: fi - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -170,6 +175,7 @@ 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 + persist-credentials: false - name: Login to container registry env: REGISTRY: ${{ inputs.container-registry }} @@ -210,6 +216,8 @@ jobs: fi - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -217,6 +225,7 @@ 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 + persist-credentials: false - name: Login to container registry env: REGISTRY: ${{ inputs.container-registry }} @@ -250,6 +259,7 @@ 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 + persist-credentials: false - name: Login to container registry env: REGISTRY: ${{ inputs.container-registry }} diff --git a/.github/workflows/reusable-container-scan.yml b/.github/workflows/reusable-container-scan.yml index 69a0998..93f196f 100644 --- a/.github/workflows/reusable-container-scan.yml +++ b/.github/workflows/reusable-container-scan.yml @@ -76,6 +76,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -83,6 +85,7 @@ 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 + persist-credentials: false # 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 diff --git a/.github/workflows/reusable-dotnet-quality.yml b/.github/workflows/reusable-dotnet-quality.yml index 3903009..c53bcdd 100644 --- a/.github/workflows/reusable-dotnet-quality.yml +++ b/.github/workflows/reusable-dotnet-quality.yml @@ -161,6 +161,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -168,6 +169,7 @@ 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 + persist-credentials: false - name: Install .NET & lint uses: ./workflow-parts/actions/dotnet/install-lint-restore with: diff --git a/.github/workflows/reusable-git-check.yml b/.github/workflows/reusable-git-check.yml index 4f69d10..e585c1e 100644 --- a/.github/workflows/reusable-git-check.yml +++ b/.github/workflows/reusable-git-check.yml @@ -59,6 +59,7 @@ jobs: with: # The base of the comparison is in the history. fetch-depth: 0 + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -66,6 +67,7 @@ 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 + persist-credentials: false - name: Check changes id: changes uses: ./workflow-parts/actions/git/check-changes diff --git a/.github/workflows/reusable-markup-lint.yml b/.github/workflows/reusable-markup-lint.yml index 84077f5..04a5f22 100644 --- a/.github/workflows/reusable-markup-lint.yml +++ b/.github/workflows/reusable-markup-lint.yml @@ -35,6 +35,8 @@ jobs: steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # 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.24.0 "**/*.md" diff --git a/.github/workflows/reusable-node-quality.yml b/.github/workflows/reusable-node-quality.yml index d360e7c..37a20c6 100644 --- a/.github/workflows/reusable-node-quality.yml +++ b/.github/workflows/reusable-node-quality.yml @@ -171,6 +171,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/reusable-npm-publication.yml b/.github/workflows/reusable-npm-publication.yml index d356f99..6f63896 100644 --- a/.github/workflows/reusable-npm-publication.yml +++ b/.github/workflows/reusable-npm-publication.yml @@ -79,6 +79,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/.github/workflows/reusable-pages-deployment.yml b/.github/workflows/reusable-pages-deployment.yml index 85b2dd4..d008fbc 100644 --- a/.github/workflows/reusable-pages-deployment.yml +++ b/.github/workflows/reusable-pages-deployment.yml @@ -94,6 +94,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: ${{ inputs.fetch-depth }} + persist-credentials: false - name: Checkout workflow parts if: inputs.task-enabled uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -102,6 +103,7 @@ 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 + persist-credentials: false - name: Setup Task if: inputs.task-enabled uses: ./workflow-parts/actions/task/setup diff --git a/.github/workflows/reusable-terraform-deployment.yml b/.github/workflows/reusable-terraform-deployment.yml index 727cc6e..2e85759 100644 --- a/.github/workflows/reusable-terraform-deployment.yml +++ b/.github/workflows/reusable-terraform-deployment.yml @@ -102,6 +102,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -109,6 +111,7 @@ 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 + persist-credentials: false - name: Add runner ID to MongoDB Atlas uses: ./workflow-parts/actions/mongodb-atlas/add-runner-ip with: diff --git a/.github/workflows/reusable-terraform-quality.yml b/.github/workflows/reusable-terraform-quality.yml index d16b654..0bf2271 100644 --- a/.github/workflows/reusable-terraform-quality.yml +++ b/.github/workflows/reusable-terraform-quality.yml @@ -40,6 +40,8 @@ jobs: steps: - name: Clone repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Checkout workflow parts uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -47,6 +49,7 @@ 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 + persist-credentials: false - name: Cache Terraform plugins uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: From 99e797d0672c8a4fb39c4174173fa677c00a5d1e Mon Sep 17 00:00:00 2001 From: Bertrand THOMAS Date: Wed, 28 Oct 2026 22:59:41 +0100 Subject: [PATCH 5/5] Publish Helm charts with a reusable workflow running chart-releaser 1.9.0 from a checksummed release, which uploads the assets before publishing a release, as an immutable release refuses them afterwards Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 25 ++- .../workflows/reusable-helm-publication.yml | 166 ++++++++++++++++++ README.md | 6 + actions/chart-releaser/setup/action.yml | 41 +++++ actions/helm-repo-html/setup/action.yml | 41 +++++ test/check_workflow_parts.py | 1 + 6 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/reusable-helm-publication.yml create mode 100644 actions/chart-releaser/setup/action.yml create mode 100644 actions/helm-repo-html/setup/action.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0df302e..ae0477f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,18 @@ jobs: uses: ./actions/opengrep/setup with: opengrep-sha256: "0000000000000000000000000000000000000000000000000000000000000000" + - name: Install chart-releaser with a checksum that does not match + id: chart-releaser-wrong-checksum + continue-on-error: true + uses: ./actions/chart-releaser/setup + with: + chart-releaser-sha256: "0000000000000000000000000000000000000000000000000000000000000000" + - name: Install helm-repo-html with a checksum that does not match + id: helm-repo-html-wrong-checksum + continue-on-error: true + uses: ./actions/helm-repo-html/setup + with: + helm-repo-html-sha256: "0000000000000000000000000000000000000000000000000000000000000000" - name: Install a kind release that does not exist id: kind-missing-release continue-on-error: true @@ -199,7 +211,7 @@ jobs: command -v helm test-kubernetes-tools-setup: - name: "Test / kube-linter, kubeconform, helm-unittest and kind setup actions" + name: "Test / kube-linter, kubeconform, helm-unittest, kind, chart-releaser and helm-repo-html setup actions" runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -217,6 +229,10 @@ jobs: uses: ./actions/helm-unittest/setup - name: Setup kind uses: ./actions/kind/setup + - name: Setup chart-releaser + uses: ./actions/chart-releaser/setup + - name: Setup helm-repo-html + uses: ./actions/helm-repo-html/setup - name: Verify the tools run on a chart run: | helm create "${RUNNER_TEMP}/demo" >/dev/null @@ -228,6 +244,13 @@ jobs: untt "${RUNNER_TEMP}/demo" kind version kubectl version --client + cr package "${RUNNER_TEMP}/demo" --package-path "${RUNNER_TEMP}/packages" + helm repo index "${RUNNER_TEMP}/packages" + # A Go template, whose variables the shell must leave alone. + # shellcheck disable=SC2016 + echo '{{ range $name, $versions := .Entries }}{{ $name }}{{ end }}' > "${RUNNER_TEMP}/packages/index.tpl" + helm-repo-html --input "${RUNNER_TEMP}/packages/index.yaml" --template "${RUNNER_TEMP}/packages/index.tpl" --output "${RUNNER_TEMP}/packages/index.html" + grep -qx demo "${RUNNER_TEMP}/packages/index.html" test-scanner-setup: name: "Test / Opengrep, Trivy and CodeQL setup actions" diff --git a/.github/workflows/reusable-helm-publication.yml b/.github/workflows/reusable-helm-publication.yml new file mode 100644 index 0000000..29e6aed --- /dev/null +++ b/.github/workflows/reusable-helm-publication.yml @@ -0,0 +1,166 @@ +name: Reusable - Helm publication + +# The caller grants "contents: write" to create the releases and push the index, "pages: write" and "id-token: write" to deploy the Pages, +# and the repository has a Pages branch holding index.yaml, published from GitHub Actions. +on: + workflow_call: + inputs: + charts-directory: + description: Directory holding one chart per subdirectory, relative to the root of the repository + type: string + required: false + default: charts + job-name: + description: Job name + type: string + required: false + default: Publication + operating-system: + description: Operating system executing the runner + type: string + required: false + default: ubuntu-latest + pages-branch: + description: Branch holding the index.yaml of the chart repository, and an index.tpl to render it as index.html + type: string + required: false + default: gh-pages + timeout-minutes: + description: Minutes each 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" + outputs: + page-url: + description: URL of the chart repository + value: ${{ jobs.pages-deploy.outputs.page-url }} + published-charts: + description: Charts published by the run, as name-version separated by spaces, empty when every version already was + value: ${{ jobs.helm-publication.outputs.published-charts }} + +jobs: + helm-publication: + name: ${{ inputs.job-name }} + runs-on: ${{ inputs.operating-system }} + timeout-minutes: ${{ inputs.timeout-minutes }} + outputs: + published-charts: ${{ steps.package.outputs.published-charts }} + steps: + # The whole history and every branch, as chart-releaser adds a worktree of the Pages branch from the remote one. + - name: Clone repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + # chart-releaser pushes with a URL carrying its own token. + persist-credentials: false + - 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: Exclude workflow parts from the repository + run: echo "/workflow-parts/" >> .git/info/exclude + - name: Setup Helm + uses: ./workflow-parts/actions/helm/setup + - name: Setup chart-releaser + uses: ./workflow-parts/actions/chart-releaser/setup + # Helm resolves a dependency of a chart through a repository added under any name with the same URL. + - name: Add the repositories of the chart dependencies + env: + CHARTS_DIRECTORY: ${{ inputs.charts-directory }} + run: | + # An OCI or a file dependency needs no repository. + urls=$({ grep -h '^[[:space:]]*repository:' "${CHARTS_DIRECTORY}"/*/Chart.yaml || true; } | awk '{ print $2 }' | tr -d "\"'" | { grep -E '^https?://' || true; } | sort -u) + if [[ -z "$urls" ]]; then + echo "No chart repository to add" + exit 0 + fi + index=0 + while read -r url; do + index=$((index + 1)) + helm repo add "dependency-${index}" "$url" + done <<< "$urls" + helm repo update + # A version already in the index is never packaged again, so a run after a failure publishes what the failure left out. + - name: Package the charts not yet published + id: package + env: + CHARTS_DIRECTORY: ${{ inputs.charts-directory }} + PAGES_BRANCH: ${{ inputs.pages-branch }} + run: | + index=$(git show "origin/${PAGES_BRANCH}:index.yaml") + published=() + for chart in "${CHARTS_DIRECTORY}"/*/; do + chart="${chart%/}" + [[ -f "${chart}/Chart.yaml" ]] || continue + release=$(helm show chart "$chart" | awk ' + /^name:/ { name = $2 } + /^version:/ { version = $2 } + END { gsub(/["\047]/, "", name); gsub(/["\047]/, "", version); print name "-" version }') + # chart-releaser lists each package by the URL of its release asset. + if grep -q "/${release}\.tgz$" <<< "$index"; then + continue + fi + cr package "$chart" --package-path .cr-release-packages + published+=("$release") + done + echo "published-charts=${published[*]}" >> "$GITHUB_OUTPUT" + if [[ ${#published[@]} -eq 0 ]]; then + echo "Every chart version is already published" + fi + - name: Release the charts and push the index + if: steps.package.outputs.published-charts != '' + env: + CR_TOKEN: ${{ github.token }} + PAGES_BRANCH: ${{ inputs.pages-branch }} + run: | + owner="${GITHUB_REPOSITORY%%/*}" + repository="${GITHUB_REPOSITORY#*/}" + # A release left by a run that failed afterwards is kept, as an immutable release cannot be changed anyway. + cr upload --owner "$owner" --git-repo "$repository" --commit "$GITHUB_SHA" --package-path .cr-release-packages --skip-existing + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + mkdir -p .cr-index + cr index --owner "$owner" --git-repo "$repository" --package-path .cr-release-packages --index-path .cr-index/index.yaml --pages-branch "$PAGES_BRANCH" --push + + # The Pages branch is read again, as the index pushed above is not in the clone. + - name: Checkout the Pages branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.pages-branch }} + path: pages-site + persist-credentials: false + - name: Setup helm-repo-html + if: hashFiles('pages-site/index.tpl') != '' + uses: ./workflow-parts/actions/helm-repo-html/setup + - name: Render the index page + if: hashFiles('pages-site/index.tpl') != '' + working-directory: pages-site + run: helm-repo-html --input index.yaml --template index.tpl --output index.html + - name: Upload the site + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: pages-site + + pages-deploy: + name: Deploy + needs: helm-publication + 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/README.md b/README.md index 4046287..532f31f 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ GitHub Pages: - [Build a site and deploy it](.github/workflows/reusable-pages-deployment.yml) +Helm: + +- [Publish the new chart versions as GitHub releases and a chart repository on GitHub Pages](.github/workflows/reusable-helm-publication.yml) + Markup (Markdown, YAML, shell scripts): - [Ensure the quality of the content](.github/workflows/reusable-markup-lint.yml) @@ -78,7 +82,9 @@ Go: Helm: - [Setup Helm](actions/helm/setup/action.yml) +- [Setup chart-releaser](actions/chart-releaser/setup/action.yml) - [Setup chart-testing](actions/chart-testing/setup/action.yml) +- [Setup helm-repo-html](actions/helm-repo-html/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) diff --git a/actions/chart-releaser/setup/action.yml b/actions/chart-releaser/setup/action.yml new file mode 100644 index 0000000..2dfe726 --- /dev/null +++ b/actions/chart-releaser/setup/action.yml @@ -0,0 +1,41 @@ +name: Setup chart-releaser +description: | + Installs chart-releaser (cr) by downloading the official binary from its GitHub release and verifying its SHA256 checksum, pinned here rather than read from the release it would vouch for + (replacement for helm/chart-releaser-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: + chart-releaser-version: + description: Version of chart-releaser to install, v1.9.0 or later for a repository with immutable releases (check latest from https://github.com/helm/chart-releaser/releases) + required: false + default: "v1.9.0" + chart-releaser-sha256: + description: SHA256 of the chart-releaser archive for x64, given with any other chart-releaser-version + required: false + default: "8131cb6016e2bcc9e4f63cadfdd7026ae3fa4d2c4d319f4cb72ff3c6a43a5a36" + chart-releaser-sha256-arm64: + description: SHA256 of the chart-releaser archive for arm64, given with any other chart-releaser-version + required: false + default: "fb01f90a8097083bcdd14de0ef8976c42ddfba302ded3a395464857eb832d036" + +runs: + using: "composite" + steps: + - name: Install chart-releaser + shell: bash + env: + CHART_RELEASER_VERSION: ${{ inputs.chart-releaser-version }} + CHART_RELEASER_ARCH: ${{ runner.arch == 'ARM64' && 'arm64' || 'amd64' }} + CHART_RELEASER_SHA256: ${{ runner.arch == 'ARM64' && inputs.chart-releaser-sha256-arm64 || inputs.chart-releaser-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)" + tarball="chart-releaser_${CHART_RELEASER_VERSION#v}_linux_${CHART_RELEASER_ARCH}.tar.gz" + curl -fsSL -O "https://github.com/helm/chart-releaser/releases/download/${CHART_RELEASER_VERSION}/${tarball}" + echo "${CHART_RELEASER_SHA256} ${tarball}" | sha256sum --check --status + tar -xzf "${tarball}" cr + # 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 cr "${RUNNER_TEMP}/bin/cr" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/bin/cr" version diff --git a/actions/helm-repo-html/setup/action.yml b/actions/helm-repo-html/setup/action.yml new file mode 100644 index 0000000..06c9ae6 --- /dev/null +++ b/actions/helm-repo-html/setup/action.yml @@ -0,0 +1,41 @@ +name: Setup helm-repo-html +description: | + Installs helm-repo-html, which renders the index.html of a Helm chart repository from its index.yaml and a Go template, + by downloading the official binary from its GitHub release and verifying its SHA256 checksum, pinned here rather than read from the release it would vouch for + (replacement for go install, which builds the latest code of every dependency the module allows). + Linux x64 or arm64 runners only (for example ubuntu-latest or ubuntu-24.04-arm). + +inputs: + helm-repo-html-version: + description: Version of helm-repo-html to install (check latest from https://github.com/halkeye/helm-repo-html/releases) + required: false + default: "v0.2.1" + helm-repo-html-sha256: + description: SHA256 of the helm-repo-html archive for x64, given with any other helm-repo-html-version + required: false + default: "d83d96899dc79986336e6fa32ffd9245c487510dd458c741fceb1eaadcff4168" + helm-repo-html-sha256-arm64: + description: SHA256 of the helm-repo-html archive for arm64, given with any other helm-repo-html-version + required: false + default: "338bd99388ad672cb3bdb2cf8c16d23db66b9ac3b8951a660e2e9fd460bbfb95" + +runs: + using: "composite" + steps: + - name: Install helm-repo-html + shell: bash + env: + HELM_REPO_HTML_VERSION: ${{ inputs.helm-repo-html-version }} + HELM_REPO_HTML_TARBALL: helm-repo-html_Linux_${{ runner.arch == 'ARM64' && 'arm64' || 'x86_64' }}.tar.gz + HELM_REPO_HTML_SHA256: ${{ runner.arch == 'ARM64' && inputs.helm-repo-html-sha256-arm64 || inputs.helm-repo-html-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/halkeye/helm-repo-html/releases/download/${HELM_REPO_HTML_VERSION}/${HELM_REPO_HTML_TARBALL}" + echo "${HELM_REPO_HTML_SHA256} ${HELM_REPO_HTML_TARBALL}" | sha256sum --check --status + tar -xzf "${HELM_REPO_HTML_TARBALL}" bin/helm-repo-html + # 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 bin/helm-repo-html "${RUNNER_TEMP}/bin/helm-repo-html" + echo "${RUNNER_TEMP}/bin" >> "$GITHUB_PATH" + "${RUNNER_TEMP}/bin/helm-repo-html" --help > /dev/null diff --git a/test/check_workflow_parts.py b/test/check_workflow_parts.py index 168c3b2..c01951a 100755 --- a/test/check_workflow_parts.py +++ b/test/check_workflow_parts.py @@ -23,6 +23,7 @@ "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-helm-publication.yml": "creates GitHub releases and pushes to the Pages branch of the repository", ".github/workflows/reusable-terraform-deployment.yml": "needs MongoDB Atlas credentials and a Terraform backend", }