Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions .github/CI.md
Original file line number Diff line number Diff line change
@@ -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)
6 changes: 6 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
183 changes: 183 additions & 0 deletions .github/scripts/dist-tarballs-report.js
Original file line number Diff line number Diff line change
@@ -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<AsyncFunctionArguments, 'github' | 'context'>} GitHubArgs
* @typedef {Pick<AsyncFunctionArguments, 'github' | 'context' | 'core'>} GitHubCoreArgs
*/

/** Hidden marker by which our PR comment is found and updated */
const COMMENT_MARKER = '<!-- GHA-01:tarball-url -->';

/** Marker of comments posted earlier by thollander/actions-comment-pull-request */
const OLD_COMMENT_MARKERS = ['<!-- thollander/actions-comment-pull-request "tarball-url" -->'];

/** 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<number | undefined>}
*/
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,
};
90 changes: 90 additions & 0 deletions .github/scripts/pr-comments.js
Original file line number Diff line number Diff line change
@@ -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<AsyncFunctionArguments, 'github' | 'context'>} 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. "<!-- my-workflow:my-comment -->"
* @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,
};
38 changes: 0 additions & 38 deletions .github/workflows/00-preclean.yml

This file was deleted.

Loading
Loading