diff --git a/.github/CI.md b/.github/CI.md new file mode 100644 index 0000000000..52f12c736b --- /dev/null +++ b/.github/CI.md @@ -0,0 +1,47 @@ +# Working on CI (GitHub Actions) + +This covers the GitHub Actions workflows and helper scripts under `.github/`. + +## Checks before pushing changes to `.github/` + +- **zizmor**, with the same version and settings as the "GHA-06: zizmor" + check (see `.github/workflows/06-zizmor.yml`): + + pipx run zizmor==1.25.2 --min-severity medium .github/ + + Record deliberate exceptions, with the reason, in `.github/zizmor.yml`. + +- **actionlint** on the workflows, e.g.: + + docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:1.7.7 .github/workflows/*.yml + + Some shellcheck style notes on existing code are known; do not add new ones. + +- **`tsc --strict`** for `.github/scripts/*.js`, see the header of + `.github/scripts/pr-comments.js` for the command. There is no `package.json` + on purpose: NUT is a C project, so the TypeScript tooling is installed ad hoc + (`npm install --no-save` in a scratch directory). + +## Rules + +- **Never trust user-controlled input:** PR titles and descriptions, branch + names, commit messages, comments, and anything produced by PR builds + (artifacts and their names). Do not expand `${{ ... }}` of such values into + `run:` or `script:`; pass them via `env:` and treat them as data. +- **Least privilege:** top-level `permissions: {}`, and each job only gets the + permissions it needs. PR code runs only on `pull_request`, with a read-only + token. Jobs with write permissions (e.g. on `workflow_run`) never check out + or run PR code. Use `persist-credentials: false` on checkouts. +- **Avoid `pull_request_target`** if possible. If it is really needed, follow + [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target). +- **Caches:** only trusted runs (master branch pushes, scheduled runs) save + caches that master branch builds restore. +- **No new third-party actions,** unless their benefit clearly outweighs the + risk of a compromised action; prefer `run:` steps or `actions/github-script`. + If one is needed, pin it to a full commit SHA with the version in a comment + (enforced by zizmor, updated by Dependabot). + +## References + +- [Secure use reference](https://docs.github.com/en/actions/reference/security/secure-use) +- [Securely using pull_request_target](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e43e1a1135..95ccc58ca6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,3 +10,9 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + # Only propose action versions published at least a week ago, so that a + # compromised release is more likely to be found and pulled before we + # pick it up (for SHA-pinned actions as well as for GitHub's own ones, + # which are referenced by tags, see .github/zizmor.yml) + cooldown: + default-days: 7 diff --git a/.github/scripts/dist-tarballs-report.js b/.github/scripts/dist-tarballs-report.js new file mode 100644 index 0000000000..f1362a4d25 --- /dev/null +++ b/.github/scripts/dist-tarballs-report.js @@ -0,0 +1,183 @@ +// @ts-check +/* + * Report results of the "GHA-01: Tarballs" workflow (01-make-dist.yml): + * a PR comment with a link to the tarballs artifact, a commit status, and + * clean-up of older artifacts. Called by the "GHA-01: Tarballs Report" + * workflow (01-make-dist-report.yml) when a GHA-01 run is requested or + * completed: + * + * const report = require('./.github/scripts/dist-tarballs-report.js'); + * await report.run({ github, context, core }); + * + * That workflow has write permissions and runs for builds of untrusted + * PR code, so this file comes from the trusted default branch, and all + * details of the GHA-01 run (passed via environment variables, see "Env:" + * below) are treated as data: the PR is looked up via the API and must + * match the run's head commit, and the artifact name is computed here. + * See pr-comments.js for how to type-check this. + */ + +const prComments = require('./pr-comments.js'); + +/** + * @typedef {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments + * @typedef {Pick} GitHubArgs + * @typedef {Pick} GitHubCoreArgs + */ + +/** Hidden marker by which our PR comment is found and updated */ +const COMMENT_MARKER = ''; + +/** Marker of comments posted earlier by thollander/actions-comment-pull-request */ +const OLD_COMMENT_MARKERS = ['']; + +/** Name of the commit status with the tarball link */ +const STATUS_CONTEXT = 'GHA-01: Tarballs / download link'; + +/** + * Read an environment variable set by the workflow step. + * @param {string} name + * @returns {string} + */ +function env(name) { + return process.env[name] || ''; +} + +/** + * The open PR whose head is the commit built by the GHA-01 run, if that + * run was for a pull_request event. Looked up by head owner and branch, + * as workflow_run events do not list PRs from forks. + * Env: RUN_EVENT, HEAD_OWNER, HEAD_BRANCH, HEAD_SHA + * @param {GitHubArgs} args + * @returns {Promise} + */ +async function findPr({ github, context }) { + if (env('RUN_EVENT') !== 'pull_request') { + return undefined; + } + const { owner, repo } = context.repo; + const pulls = await github.paginate(github.rest.pulls.list, + { owner, repo, state: 'open', head: `${env('HEAD_OWNER')}:${env('HEAD_BRANCH')}`, per_page: 100 }); + const pr = pulls.find(p => p.head.sha === env('HEAD_SHA')); + if (!pr) { + console.log(`No open PR found with head commit ${env('HEAD_SHA')} (maybe updated since)`); + } + return pr?.number; +} + +/** + * Artifact name as set by 01-make-dist.yml: by PR number, or by branch + * or tag name (with characters not valid in artifact names replaced). + * Env: HEAD_BRANCH + * @param {number | undefined} pr + * @returns {string} + */ +function artifactName(pr) { + const name = pr ? `PR-${pr}` : env('HEAD_BRANCH'); + return `NUT-tarballs-${name.replace(/[^A-Za-z0-9._+-]/g, '_')}`; +} + +/** + * The tarballs artifact uploaded by the GHA-01 run, if any. + * Env: RUN_ID + * @param {GitHubArgs} args + * @param {string} name + */ +async function findArtifact({ github, context }, name) { + const { owner, repo } = context.repo; + const res = await github.rest.actions.listWorkflowRunArtifacts( + { owner, repo, run_id: Number(env('RUN_ID')), name }); + return res.data.artifacts[0]; +} + +/** + * Delete older artifacts with the same name (from earlier runs for this + * PR or feature branch), keeping the given one. Not for master or tags. + * Env: RUN_EVENT, HEAD_BRANCH + * @param {GitHubArgs} args + * @param {string} name + * @param {number} keep - artifact ID + */ +async function deleteOldArtifacts({ github, context }, name, keep) { + const { owner, repo } = context.repo; + if (env('RUN_EVENT') !== 'pull_request') { + if (env('HEAD_BRANCH') === 'master') { + return; + } + try { + await github.rest.git.getRef({ owner, repo, ref: `tags/${env('HEAD_BRANCH')}` }); + return; + } catch (e) { + // Not a tag, so a feature branch + } + } + const artifacts = await github.paginate(github.rest.actions.listArtifactsForRepo, + { owner, repo, name, per_page: 100 }); + for (const a of artifacts) { + if (a.name === name && a.id !== keep) { + console.log(`Deleting artifact ${a.id}`); + await github.rest.actions.deleteArtifact({ owner, repo, artifact_id: a.id }); + } + } +} + +/** + * Handle a requested or completed GHA-01 run. + * Env: RUN_ACTION, RUN_CONCLUSION, RUN_URL, HEAD_SHA (and those above) + * @param {GitHubCoreArgs} args + */ +async function run({ github, context, core }) { + const { owner, repo } = context.repo; + const sha = env('HEAD_SHA'); + const runLink = `[tarball build workflow run](${env('RUN_URL')})`; + const pr = await findPr({ github, context }); + + if (env('RUN_ACTION') === 'requested') { + if (pr) { + // AppVeyor posts new comments for new commits + await prComments.deletePrCommentsBy({ github, context }, pr, 'AppVeyorBot'); + await prComments.upsertPrComment({ github, context }, pr, COMMENT_MARKER, + `Preparing a ZIP file with standard source tarball and another tarball with pre-built docs for commit ${sha} ...`, + OLD_COMMENT_MARKERS); + } + return; + } + + const name = artifactName(pr); + const artifact = await findArtifact({ github, context }, name); + const url = artifact ? `${env('RUN_URL')}/artifacts/${artifact.id}` : ''; + if (artifact) { + await deleteOldArtifacts({ github, context }, name, artifact.id); + } + + if (pr) { + let body; + if (url) { + body = `A ZIP file with standard source tarball and another tarball with pre-built docs for commit ${sha} is temporarily available: [${name}.zip](${url}).`; + if (env('RUN_CONCLUSION') !== 'success') { + body += `\n\n:warning: Some checks in the ${runLink} failed.`; + } + } else { + body = `:x: Could not prepare the tarballs for commit ${sha}, see the ${runLink}.`; + } + await prComments.upsertPrComment({ github, context }, pr, COMMENT_MARKER, body, OLD_COMMENT_MARKERS); + } + + // Unlike for check runs created with GITHUB_TOKEN, the target_url of a + // commit status is honored: "Details" in the PR checks opens the artifact + await github.rest.repos.createCommitStatus({ + owner, repo, sha, + context: STATUS_CONTEXT, + state: url ? 'success' : 'failure', + target_url: url || env('RUN_URL'), + description: url + ? `Dist and Docs tarballs for ${sha.substring(0, 9)}` + : `No tarballs for ${sha.substring(0, 9)}, see the workflow run`, + }); + core.info(url ? `Reported ${url}` : 'No tarballs artifact found'); +} + +module.exports = { + artifactName, + run, +}; diff --git a/.github/scripts/pr-comments.js b/.github/scripts/pr-comments.js new file mode 100644 index 0000000000..aa67344315 --- /dev/null +++ b/.github/scripts/pr-comments.js @@ -0,0 +1,90 @@ +// @ts-check +/* + * Generic helpers to manage comments on a pull request, for use in + * actions/github-script steps, e.g.: + * + * const prComments = require('./.github/scripts/pr-comments.js'); + * await prComments.deletePrCommentsBy({ github, context }, 123, 'AppVeyorBot'); + * + * Steps using this need "pull-requests: write" permission, so this file + * must be checked out from the trusted default branch there, never from + * a PR source. + * + * For type checking (not needed to run it in a workflow), in a scratch + * directory with a copy of the .github/scripts/ files (the types of + * actions/github-script are not published on npm, only on GitHub): + * npm install --no-save typescript @types/node \ + * "@actions/github-script@github:actions/github-script" + * npx tsc --noEmit --allowJs --checkJs --strict --skipLibCheck \ + * --module node16 --moduleResolution node16 --types node \ + * .github/scripts/*.js + */ + +/** + * @typedef {import('@actions/github-script').AsyncFunctionArguments} AsyncFunctionArguments + * @typedef {Pick} GitHubArgs + */ + +/** Account which posts comments with the GITHUB_TOKEN of a workflow */ +const WORKFLOW_COMMENT_AUTHOR = 'github-actions[bot]'; + +/** + * List all comments of a pull request. + * @param {GitHubArgs} args + * @param {number} issue_number - the PR number + */ +async function listPrComments({ github, context }, issue_number) { + const { owner, repo } = context.repo; + return github.paginate(github.rest.issues.listComments, + { owner, repo, issue_number, per_page: 100 }); +} + +/** + * Create a comment of this workflow on the PR, identified by a hidden + * marker (an HTML comment, added to the body), or update it if it + * exists already, so the PR has one such comment that stays current. + * @param {GitHubArgs} args + * @param {number} issue_number - the PR number + * @param {string} marker - e.g. "" + * @param {string} body - Markdown text + * @param {string[]} [oldMarkers] - markers of earlier versions of this + * comment (e.g. by another tool) to take over and update as well + */ +async function upsertPrComment({ github, context }, issue_number, marker, body, oldMarkers = []) { + const { owner, repo } = context.repo; + const fullBody = `${body}\n\n${marker}`; + const markers = [marker, ...oldMarkers]; + const comments = await listPrComments({ github, context }, issue_number); + const old = comments.find(c => c.user?.login === WORKFLOW_COMMENT_AUTHOR + && markers.some(m => c.body?.includes(m))); + if (old) { + await github.rest.issues.updateComment({ owner, repo, comment_id: old.id, body: fullBody }); + console.log(`Updated comment ${old.id}`); + } else { + const res = await github.rest.issues.createComment( + { owner, repo, issue_number, body: fullBody }); + console.log(`Created comment ${res.data.id}`); + } +} + +/** + * Delete all comments on the PR posted by the given account, e.g. + * outdated build reports of another CI system. + * @param {GitHubArgs} args + * @param {number} issue_number - the PR number + * @param {string} login - e.g. "AppVeyorBot" + */ +async function deletePrCommentsBy({ github, context }, issue_number, login) { + const { owner, repo } = context.repo; + const comments = await listPrComments({ github, context }, issue_number); + for (const c of comments.filter(c => c.user?.login === login)) { + await github.rest.issues.deleteComment({ owner, repo, comment_id: c.id }); + console.log(`Deleted comment ${c.id} by ${login}`); + } +} + +module.exports = { + deletePrCommentsBy, + listPrComments, + upsertPrComment, +}; diff --git a/.github/workflows/00-preclean.yml b/.github/workflows/00-preclean.yml deleted file mode 100644 index f94517d19a..0000000000 --- a/.github/workflows/00-preclean.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: "GHA-00: Pre-clean" - -on: - push: - branches: [ "master", "FTY", "fightwarn", "FTY-obs" ] - tags: - - v* - pull_request_target: - # The branches below must be a subset of the branches above - # Note that for PRs this runs the copy of workflow in the - # target branch (and only then gets some of the permissions - # listed below), and identification/naming of PR source vs. - # a pushed branch (e.g. master updated by a PR merge) is - # tricky. - branches: [ "master", "FTY", "fightwarn", "FTY-obs" ] - workflow_dispatch: - # Allow manually running the action, e.g. if disabled after some quietness in the source - -permissions: - checks: write - contents: write - issues: write - pull-requests: write - -jobs: - clean: - runs-on: ubuntu-latest - steps: - - name: pr-deleter - if: github.event.pull_request.number > 0 - uses: maheshrayas/action-pr-comment-delete@v3.0 - with: - github_token: '${{ secrets.GITHUB_TOKEN }}' - org: networkupstools # '${{context.repo.owner}}' - repo: nut # '${{context.repo.repo}}' - user: 'AppVeyorBot' - # user: 'github-actions[bot]' #commented by the userid - issue: '${{github.event.number}}' diff --git a/.github/workflows/01-make-dist-report.yml b/.github/workflows/01-make-dist-report.yml new file mode 100644 index 0000000000..87eae60902 --- /dev/null +++ b/.github/workflows/01-make-dist-report.yml @@ -0,0 +1,55 @@ +# Report results of "GHA-01: Tarballs" (01-make-dist.yml) runs: delete old +# AppVeyor comments and post a "Preparing" PR comment when a run starts, +# and when it completes, update that comment with the tarballs link, set +# a commit status, and delete older artifacts of the same PR or feature +# branch (not of master or tags). +# +# Triggered by workflow_run, so it always runs this workflow (and the +# .github/scripts/) from the default branch, with the write permissions +# below, also for PRs from forks. It never checks out or runs PR code: +# details of the GHA-01 run are only used as data, via env (see +# .github/scripts/dist-tarballs-report.js). + +name: "GHA-01: Tarballs Report" + +on: + workflow_run: + workflows: [ "GHA-01: Tarballs" ] + types: [ requested, completed ] + +permissions: {} + +jobs: + report-dist-tarballs: + name: "Report Dist and Docs Tarballs" + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + pull-requests: write + statuses: write + + steps: + + # Default ref: the trusted default branch. Do not add a "ref:" here. + - name: Checkout GHA-01 helper scripts + uses: actions/checkout@v7 + with: + sparse-checkout: .github/scripts + persist-credentials: false + + - name: "GHA-01: Report to PR comment and commit status" + uses: actions/github-script@v9 + env: + RUN_ACTION: ${{ github.event.action }} + RUN_ID: ${{ github.event.workflow_run.id }} + RUN_URL: ${{ github.event.workflow_run.html_url }} + RUN_EVENT: ${{ github.event.workflow_run.event }} + RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_OWNER: ${{ github.event.workflow_run.head_repository.owner.login }} + with: + script: | + const report = require('./.github/scripts/dist-tarballs-report.js'); + await report.run({ github, context, core }); diff --git a/.github/workflows/01-make-dist-worker.yml b/.github/workflows/01-make-dist-worker.yml index c131adc9bc..c305f7dd51 100644 --- a/.github/workflows/01-make-dist-worker.yml +++ b/.github/workflows/01-make-dist-worker.yml @@ -1,6 +1,7 @@ -# Separated from 01-make-dist.yml to touch untrusted sources -# in a separate job without particular permissions to anything. -# Triggered by a step in that job. +# Builds the tarballs of possibly untrusted sources (PR code), with +# read-only permissions. Called from 01-make-dist.yml as a reusable +# workflow (workflow_call), or can be started manually +# (workflow_dispatch) for any ref. # # See also: # https://github.com/actions/upload-artifact @@ -125,10 +126,16 @@ jobs: || exit date > .timestamp-init + # The ccache is restored here, and saved (see below) only by master + # branch pushes and scheduled runs. PR builds run on pull_request, in + # their own cache scope, so they can not plant a poisoned cache for + # later master branch builds. Note that a manual (workflow_dispatch) + # run uses the cache scope of the branch it is started on: do not + # start it on master with an untrusted "ref" input. - name: Prepare ccache # Based on https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching#example-using-the-cache-action example id: cache-ccache - uses: actions/cache@v6 + uses: actions/cache/restore@v6 env: compiler: 'CC=gcc CXX=g++' cache-name: cache-ccache-${{ env.compiler }} @@ -203,6 +210,17 @@ jobs: - name: CCache stats after distcheck run: ccache -sv || ccache -s || echo "FAILED to read ccache info, oh well" + - name: Save ccache (master branch pushes and scheduled runs only) + if: ${{ github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'schedule') }} + uses: actions/cache/save@v6 + with: + path: | + ~/.ccache + ~/.cache/ccache + ~/.config/ccache/ccache.conf + # Same key as computed when restoring (its .timestamp-init is gone) + key: ${{ steps.cache-ccache.outputs.cache-primary-key }} + - name: NUT CI Build to package complex docs (not part of dist tarball) run: | make -s -j 8 dist-docs @@ -218,4 +236,12 @@ jobs: compression-level: 0 overwrite: true - # FINISH: Pass control back to the main pipeline + # The PR comment and commit status with this link are set by the + # "GHA-01: Tarballs Report" workflow (01-make-dist-report.yml) + - name: Report tarball link in job summary + env: + ARTIFACT_NAME: ${{ inputs.artifact_name }} + ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }} + run: | + echo "### Dist and Docs tarballs" >> "$GITHUB_STEP_SUMMARY" + echo "[${ARTIFACT_NAME}.zip](${ARTIFACT_URL})" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/01-make-dist.yml b/.github/workflows/01-make-dist.yml index c8d68d529c..1201a1102d 100644 --- a/.github/workflows/01-make-dist.yml +++ b/.github/workflows/01-make-dist.yml @@ -1,12 +1,10 @@ -# Adapted from NUT codeql.yml with inspiration taken from -# https://javahelps.com/manage-github-artifact-storage-quota -# regarding uploads of artifacts and clearing the way for them. +# Build NUT dist and docs tarballs, and upload them as an artifact. # -# This workflow is triggered on pushes to a repository (PRs, -# branch increments, or tags). It has higher permissions to -# perform management of comments and checks, and launches a -# separate "Worker" workflow for tarball generation touching -# possibly untrusted code base. +# This workflow has no write permissions: for PRs it runs on pull_request +# (not pull_request_target), so PR code (from forks too) builds with a +# read-only token and in the PR's own cache scope. The PR comment, commit +# status and artifact clean-up are done by the "GHA-01: Tarballs Report" +# workflow (01-make-dist-report.yml), triggered by runs of this one. # # See also: # https://github.com/actions/upload-artifact @@ -20,211 +18,50 @@ on: branches: [ "master", "FTY", "fightwarn", "FTY-obs" ] tags: - v* - pull_request_target: + pull_request: # The branches below must be a subset of the branches above - # Note that for PRs this runs the copy of workflow in the - # target branch (and only then gets some of the permissions - # listed below), and identification/naming of PR source vs. - # a pushed branch (e.g. master updated by a PR merge) is - # tricky. branches: [ "master", "FTY", "fightwarn", "FTY-obs" ] schedule: - cron: '15 12 * * 0' workflow_dispatch: # Allow manually running the action, e.g. if disabled after some quietness in the source -permissions: - checks: write - actions: write - issues: write - pull-requests: write +permissions: {} jobs: - arrange-dist-tarballs: - name: "Arrange making Dist and Docs Tarballs, see workflow page for links" + name-dist-tarballs: + name: "Name Dist and Docs Tarballs" runs-on: ubuntu-latest + permissions: {} + outputs: + artifact_name: ${{ steps.set-artifact-name.outputs.artifact_name }} steps: - # https://github.com/marketplace/actions/substitute-string - # See also examples in https://github.com/dhimmel/dump-actions-context/ - # Note it warns about "unexpected input(s)" with replacement tokens below, - # as they are by design not predefined, as far as actions API is concerned. - # They still work for substitutions though. - - uses: bluwy/substitute-string-action@v4 - id: subst-github-ref-name - with: - _input-text: "${{ github.event.pull_request.number > 0 && format('PR-{0}', github.event.pull_request.number) || github.head_ref || github.ref_name }}" - " ": _ - "/": _ - + # Name the artifact by PR number, or by branch/tag name (with characters + # that are not valid in artifact names, like "/", replaced by "_"). + # The report workflow computes the same name to find the artifact. + # Values are passed via env, not expanded into the script. - name: Define Artifact Name id: set-artifact-name - run: echo "artifact_name=NUT-tarballs-${{ steps.subst-github-ref-name.outputs.result }}" >> "$GITHUB_OUTPUT" - - - name: Debug PR/branch identification - env: - github_event_pull_request_number: ${{ github.event.pull_request.number }} - PR_ref_short: ${{ format('PR-{0}', github.event.pull_request.number) }} - github_head_ref: ${{ github.head_ref }} - github_ref: ${{ github.ref }} - github_ref_name: ${{ github.ref_name }} - run: | - echo "steps.subst-github-ref-name.outputs.result='${{ steps.subst-github-ref-name.outputs.result }}'" || true - echo "github.event.pull_request.number='${github_event_pull_request_number}'" || true - echo "format('PR-{0}', github.event.pull_request.number)='${PR_ref_short}'" || true - echo "github.head_ref='${github_head_ref}'" || true - echo "github.ref='${github_ref}'" || true - echo "github.ref_name='${github_ref_name}'" || true - - - name: "GHA-01: Create/Update new GH PR comment for future link" - if: github.event.pull_request.number > 0 - continue-on-error: true - env: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: thollander/actions-comment-pull-request@v3 - with: - message: | - Preparing a ZIP file with standard source tarball and another tarball with pre-built docs for commit ${{ env.ref }} ... - comment-tag: tarball-url - github-token: ${{ env.GITHUB_TOKEN }} - - # We do not necessarily trust any code that comes from the PR branch - # (nor whatever is produced by scripts and tools running there), - # so we run that in a separate sandbox and just publish a link to - # the results later. - - name: Launch Build Worker - id: launch_worker - uses: benc-uk/workflow-dispatch@v1 - with: - workflow: "GHA-01: Tarballs Build Worker" - token: ${{ secrets.GITHUB_TOKEN }} - ref: ${{ github.event.pull_request.number > 0 && github.event.pull_request.base.ref || github.base_ref || github.head_ref || github.ref_name }} - inputs: '{ "ref": "${{ github.event.pull_request.number > 0 && github.event.pull_request.head.sha || github.sha }}", "artifact_name": "${{ steps.set-artifact-name.outputs.artifact_name }}" }' - wait-timeout-seconds: 3600 - wait-for-completion: true - sync-status: true - - # The artifact was already uploaded by the "Worker" job. - # We need to find the artifact URL and report it below. - - name: Get Artifact URL - id: get_artifact_url - uses: actions/github-script@v9 - with: - script: | - const res = await github.rest.actions.listArtifactsForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - }) - const artifactName = '${{ steps.set-artifact-name.outputs.artifact_name }}'; - // Find the most recent artifact with this name - const artifacts = res.data.artifacts - .filter(a => a.name === artifactName) - .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); - - if (artifacts.length > 0) { - const artifact = artifacts[0]; - var workflowRunId = artifact.workflow_run_id || ${{ steps.launch_worker.outputs.runId }}; - core.setOutput('artifact_url', `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${workflowRunId}/artifacts/${artifact.id}`); - core.setOutput('real_artifact_url', `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${workflowRunId}/artifacts/${artifact.id}`); - core.setOutput('artifact_id', `${artifact.id}`); - console.log(`Detected newest artifact ${artifact.id} from workflow run ${workflowRunId}`); - } else { - core.setFailed(`Artifact ${artifactName} not found`); - } - - # Inspired by https://javahelps.com/manage-github-artifact-storage-quota - # This is the part which needs "actions: write" permission. - # Note that the code below wipes everything matched by the filter! - # We may want another script block (after this cleanup of obsolete data) - # to iterate clearing the way build by build until there's X MB available - # (at least 12MB as of Nov 2025). - - if: env.GITHUB_REF_TYPE != 'tag' && steps.subst-github-ref-name.outputs.result != 'master' - name: Delete Old Artifacts for this feature branch/PR - uses: actions/github-script@v9 - id: delete_old_artifact_for_pr - continue-on-error: true - with: - script: | - const res = await github.rest.actions.listArtifactsForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - }) - - const artifact_id = '${{ steps.get_artifact_url.outputs.artifact_id }}'; - - res.data.artifacts - .filter(({ name }) => name === '${{ steps.set-artifact-name.outputs.artifact_name }}') - .forEach(({ id }) => { - if (id == artifact_id) return; - console.log(`Deleting artifact ${id}`); - github.rest.actions.deleteArtifact({ - owner: context.repo.owner, - repo: context.repo.repo, - artifact_id: id, - }) - }) - - - name: "GHA-01: Create/update new GH PR comment with link" - if: github.event.pull_request.number > 0 - continue-on-error: true - env: - artifact_name: "${{ steps.set-artifact-name.outputs.artifact_name }}.zip" - artifact_url: ${{ steps.get_artifact_url.outputs.real_artifact_url }} - ref: ${{ github.event.pull_request.head.sha || github.sha }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - uses: thollander/actions-comment-pull-request@v3 - with: - message: | - A ZIP file with standard source tarball and another tarball with pre-built docs for commit ${{ env.ref }} is temporarily available: [${{ env.artifact_name }}](${{ env.artifact_url }}). - comment-tag: tarball-url - github-token: ${{ env.GITHUB_TOKEN }} - - # NOTE: Despite the docs examples, due to GH API changes in Mar 2025 - # this action can no longer update an existing check, only create a - # new one. Also calls authenticated with "github-actions" GH App may - # not set "details_url" freely... - # https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28 - # FIXME: Construct the URL to commit ID (may be on PR source repo?) - # FIXME: The details_url seems to be ignored, and a click leads not to - # the artifact download right away, but rather to the build page of - # the workflow (which then has a message and link to the ZIP file). - # FIXME: The resulting Checks entry tends to use a random prefix - # (e.g. "GHA-05: CodeQL / URL for ...") and not necessarily the - # same as this workflow name ("GHA-01: Tarballs / URL for ...") - # There gotta be a parameter to set it in the JSON below, right? - # Maybe a pointer to the workflow/run ID?.. - - name: "GHA-01: Create new GH Check report - shell/cURL" - if: always() - continue-on-error: true env: - artifact_name: "${{ steps.set-artifact-name.outputs.artifact_name }}.zip" - artifact_url: ${{ steps.get_artifact_url.outputs.real_artifact_url }} - ref: ${{ github.event.pull_request.head.sha || github.sha }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REF_NAME: ${{ github.ref_name }} run: | - curl -L \ - -X POST \ - -H "Accept: application/vnd.github+json" \ - -H "Authorization: Bearer ${{ env.GITHUB_TOKEN }}" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - https://api.github.com/repos/${{ github.repository }}/check-runs \ - -d '{"head_sha": "${{ env.ref }}", "name": "URL for ${{ env.artifact_name }}", "details_url": "${{ env.artifact_url }}", "status": "completed", "conclusion": "${{ job.status }}", "output": {"title": "${{ env.artifact_url }}", "summary": "Dist and Docs [${{ env.artifact_name }}](${{ env.artifact_url }}) are available for commit ${{ env.ref }}"} }' - - ### This tends to use same "name" text on both sides of Checks list entry: - #- name: "GHA-01: Add link as GH Check report" - # uses: LouisBrunner/checks-action@v2.0.0 - # if: always() - # continue-on-error: true - # env: - # artifact_name: "${{ steps.set-artifact-name.outputs.artifact_name }}.zip" - # artifact_url: ${{ steps.upload_artifact.outputs.artifact-url }} - # ref: ${{ github.event.pull_request.head.sha || github.sha }} - # with: - # token: ${{ secrets.GITHUB_TOKEN }} - # name: "[${{ env.artifact_name }}](${{ env.artifact_url }})" - # status: "completed" - # conclusion: ${{ job.status }} - # details_url: ${{ env.artifact_url }} - # output: '{ "text_description": "Dist and Docs [${{ env.artifact_name }}](${{ env.artifact_url }}) are available for commit ${{ env.ref }}", "summary": "${{ job.status }}" }' + if [ -n "${PR_NUMBER}" ] ; then + NAME="PR-${PR_NUMBER}" + else + NAME="${REF_NAME}" + fi + NAME="`printf '%s' "${NAME}" | tr -c 'A-Za-z0-9._+-' '_'`" + echo "artifact_name=NUT-tarballs-${NAME}" >> "$GITHUB_OUTPUT" + + build-dist-tarballs: + name: "Build" + needs: name-dist-tarballs + permissions: + contents: read + uses: ./.github/workflows/01-make-dist-worker.yml + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + artifact_name: ${{ needs.name-dist-tarballs.outputs.artifact_name }} diff --git a/.github/workflows/06-zizmor.yml b/.github/workflows/06-zizmor.yml new file mode 100644 index 0000000000..69ffa658ee --- /dev/null +++ b/.github/workflows/06-zizmor.yml @@ -0,0 +1,46 @@ +# Static analysis of the GitHub Actions workflows (and other .github/ +# files) with zizmor (https://docs.zizmor.sh/), for changes to them. +# Deliberate choices of this repository are recorded in .github/zizmor.yml, +# so this fails only for new problems of at least "medium" severity; the +# findings are shown as annotations on the changed lines. +# +# Runs on pull_request (not pull_request_target), so for PRs from forks +# it has a read-only token and runs the copy of this workflow in the PR, +# which is fine as it needs no write permissions. + +name: "GHA-06: zizmor" + +on: + push: + branches: [ "master" ] + paths: + - '.github/**' + pull_request: + paths: + - '.github/**' + workflow_dispatch: + # Allow manually running the action, e.g. to check with a new zizmor version + +permissions: {} + +jobs: + zizmor: + name: "zizmor GitHub Actions analysis" + runs-on: ubuntu-24.04 + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + # zizmor is installed from PyPI in a pinned version (bump it here + # deliberately), and given the token for its online audits (e.g. + # known vulnerable action versions, ambiguous tag/branch refs). + - name: Run zizmor + env: + GH_TOKEN: ${{ github.token }} + run: | + pipx run zizmor==1.25.2 --format github --min-severity medium .github/ diff --git a/.github/workflows/08-PyNUTClient.yml b/.github/workflows/08-PyNUTClient.yml index 9a890a084c..6a443fc9a4 100644 --- a/.github/workflows/08-PyNUTClient.yml +++ b/.github/workflows/08-PyNUTClient.yml @@ -25,10 +25,12 @@ jobs: name: Build and publish Python 🐍 distributions 📦 to PyPI runs-on: ubuntu-latest steps: - - uses: actions/checkout@master + - uses: actions/checkout@v7 with: fetch-depth: 0 fetch-tags: true + # Later steps do not use git credentials, so do not keep them + persist-credentials: false - name: Set up Python 3.13 uses: actions/setup-python@v7 with: @@ -67,14 +69,14 @@ jobs: - name: Publish master distribution 📦 to Test PyPI # https://github.com/pypa/gh-action-pypi-publish if: ${{ !startsWith(github.ref, 'refs/tags') }} - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: scripts/python/module/dist password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository-url: https://test.pypi.org/legacy/ - name: Publish tagged release distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: scripts/python/module/dist password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 0000000000..2f1c593b8e --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,28 @@ +# Configuration of zizmor, the GitHub Actions static analysis tool used by +# the "GHA-06: zizmor" workflow (.github/workflows/06-zizmor.yml), see +# https://docs.zizmor.sh/configuration/ +# This records deliberate choices of this repository, so that the check +# only fails for new problems. Please explain the reason for each entry. + +rules: + unpinned-uses: + config: + policies: + # GitHub's own actions (actions/checkout, github/codeql-action etc.) + # may be referenced by tags (e.g. "@v7"). Note that such major + # version tags are moved to new releases by design, so this means + # we trust GitHub's own organizations (which run the workflows + # anyway), rather than that these refs can never change. + "actions/*": ref-pin + "github/*": ref-pin + # All other actions must be pinned to a full commit SHA, with the + # version in a comment (e.g. "@ # v1.2.3"), as their tags (or + # branches) can be moved to other code. Dependabot updates both. + "*": hash-pin + + dangerous-triggers: + ignore: + # Uses workflow_run to comment on PRs and set statuses with write + # permissions for GHA-01 builds of (possibly fork) PRs; it never runs + # PR code, and uses details of the build run only as data. + - 01-make-dist-report.yml diff --git a/AGENTS.md b/AGENTS.md index ae5b8078c7..5d853f83be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,8 @@ Before proposing a change, read the parts relevant to it: - `docs/developer-guide.txt` for NUT architecture, design and data flow. - `.github/pull_request_template.md` for the current contribution checklist. - `SECURITY.md` before investigating or reporting a possible vulnerability. +- `.github/CI.md` before changing GitHub Actions workflows or scripts under + `.github/`. Follow more specific guidance in those files if this summary differs from it.