diff --git a/.ci/filter-author-permissions.js b/.ci/filter-author-permissions.js new file mode 100644 index 00000000..44e3b8a1 --- /dev/null +++ b/.ci/filter-author-permissions.js @@ -0,0 +1,200 @@ +const GREETINGS = [ + "It looks like you've modified some files", + "that we can't accept as contributions.", + "The complete list of files we can't accept are:" +].join(" "); + +const WARNING = [ + "You'll need to revert and edit or rebase your changes.", + "Once you get those files reverted, we can continue with", + "review procedures. :octocat:" +].join(" "); + +const ERROR = [ + "We failed in something, it's not your fault, but you", + "will need to retrigger with \`git commit --amend --no-edit\` and", + "\`git push --force-with-lease\`." +].join(" "); + +/** + * Build the rejection comment for unauthorized file changes. + * + * @param {string} actor - GitHub username of the PR author + * @param {string} badFiles - Newline-bullet-separated file list + * @returns {string} Formatted rejection comment body + * + * @example + * const msg = get_not_allowed("some-user", "LICENSE\n- AGENT.md"); + * console.assert(msg.includes("Hey there some-user")); + * console.assert(msg.includes("- LICENSE")); + * console.assert(msg.includes("- AGENT.md")); + */ +function get_not_allowed(actor, badFiles) +{ + return (`👋 Hey there ${actor}. + +${GREETINGS} + +- ${badFiles} + +${WARNING}` + ); +} + +/** + * Append a comment link to the failure message. + * + * @param {string} msg - Base failure message + * @param {object} createdComment - GitHub API comment response + * @param {string} createdComment.data.html_url - Comment URL + * @returns {string} Failure message with comment link appended + * + * @example + * const comment = { + * data: { html_url: "https://github.com/org/repo/pull/1#issuecomment-123" } + * }; + * const result = get_failed_msg("Something failed.", comment); + * console.assert(result.includes("Something failed.")); + * console.assert(result.includes("Please see https://github.com")); + */ +function get_failed_msg(msg, createdComment) +{ + return (`${msg} + +Please see ${createdComment.data.html_url} for details.` + ); +} + +/** + * Check if PR author has permissions to edit protected files. + * + * @param {object} context - GitHub Actions context object + * @param {object} context.payload.pull_request + * @param {string} context.payload.pull_request.author_association + * @returns {boolean} `true` if COLLABORATOR, MEMBER, or OWNER + * + * @example + * const ctx_member = { + * payload: { pull_request: { author_association: "MEMBER" } } + * }; + * console.assert(is_valid_author(ctx_member) === true); + * + * @example + * const ctx_none = { + * payload: { pull_request: { author_association: "NONE" } } + * }; + * console.assert(is_valid_author(ctx_none) === false); + * + * @example + * const ctx_missing = { payload: {} }; + * console.assert(is_valid_author(ctx_missing) === false); + */ +function is_valid_author(context) +{ + const allowedAssociations = ['COLLABORATOR', 'MEMBER', 'OWNER']; + const authorAssociation = context.payload.pull_request + && context.payload.pull_request.author_association; + + return allowedAssociations.includes(authorAssociation); +} + +/** + * Post an error comment when the workflow fails internally. + * + * @param {object} github - GitHub API client + * @param {object} context - GitHub Actions context object + * @param {object} context.repo - Repository owner and name + * @param {number} context.payload.number - PR number + * @param {Error} err - The caught error + * @returns {Promise} GitHub API comment response + * + * @example + * const err = new Error("JSON.parse failed"); + * // const comment = await on_error(github, context, err); + * // console.assert(comment.data.html_url.includes("github.com")); + */ +async function on_error(github, context, err) +{ + const reviewMessage = `${ERROR} + +${err.toString()}`; + + return await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.number, + body: reviewMessage, + }); +} + +/** + * Filter author permissions on protected file changes. + * + * Posts a rejection comment and fails the workflow if an + * unauthorized contributor modifies protected files. + * Permitted authors (COLLABORATOR, MEMBER, OWNER) pass + * through silently. + * + * Requires `CHANGE_FILES` environment variable set via + * `env:` in the workflow YAML as a JSON array of changed + * file paths. + * + * @param {object} params - Destructured github-script params + * @param {object} params.github - GitHub API client + * @param {object} params.context - GitHub Actions context + * @param {string} params.context.actor - PR author username + * @param {object} params.context.payload - Webhook payload + * @param {object} params.context.repo - Repository info + * @param {object} params.core - Actions core utilities + * + * @example + * // Unauthorized contributor: + * process.env.CHANGE_FILES = '["LICENSE", "AGENT.md"]'; + * const context = { + * actor: "external-user", + * payload: { + * pull_request: { author_association: "NONE" }, + * number: 42 + * }, + * repo: { owner: "selfcustody", repo: "krux-installer" } + * }; + * // await script({github, context, core}); + * // -> posts rejection comment, calls core.setFailed() + * + * @example + * // Permitted maintainer: + * const context_member = { + * actor: "maintainer", + * payload: { + * pull_request: { author_association: "MEMBER" }, + * number: 42 + * }, + * repo: { owner: "selfcustody", repo: "krux-installer" } + * }; + * // await script({github, context: context_member, core}); + * // -> returns silently, no comment, no failure + */ +module.exports = async ({github, context, core}) => +{ + try + { + if (is_valid_author(context)) + return; + + const badFiles = JSON.parse(process.env.CHANGE_FILES).join("\n- "); + const reviewMessage = get_not_allowed(context.actor, badFiles); + const createdComment = await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.number, + body: reviewMessage, + }); + + const msg = get_failed_msg(WARNING, createdComment); + core.setFailed(msg); + } catch(err) { + const createdComment = await on_error(github, context, err); + const msg = get_failed_msg(ERROR, createdComment); + core.setFailed(msg); + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79af7ff2..17c31e95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: markdown_files: ${{ steps.filter_markdown.outputs.change_files }} steps: + - name: Checkout Code + uses: actions/checkout@v6 + - name: Check Not Allowed File Changes uses: dorny/paths-filter@v4 id: filter_not_allowed @@ -25,34 +28,29 @@ jobs: filters: | change: - 'LICENSE' - + - 'AGENT.md' + - 'CLAUDE.md' + - 'SECURITY.md' + - 'CHANGELOG.md' + - 'CONTRIBUTING.md' + - '.github/workflows/*.yml' + - 'pyproject.toml' + - 'poetry.lock' + # ref: https://github.com/github/docs/blob/main/.github/workflows/triage-unallowed-contributions.yml - name: Comment About Changes We Can't Accept if: ${{ steps.filter_not_allowed.outputs.change == 'true' }} uses: actions/github-script@v8 + id: filter_author_permissions + env: + CHANGE_FILES: ${{ steps.filter_not_allowed.outputs.change_files }} with: script: | - let workflowFailMessage = "It looks like you've modified some files that we can't accept as contributions." - try { - const badFilesArr = [ - 'LICENSE' - ] - const badFiles = badFilesArr.join('\n- ') - const reviewMessage = `👋 Hey there kruxer. It looks like you've modified some files that we can't accept as contributions. The complete list of files we can't accept are:\n- ${badFiles}\n\nYou'll need to revert all of the files you changed in that list using [GitHub Desktop](https://docs.github.com/en/free-pro-team@latest/desktop/contributing-and-collaborating-using-github-desktop/managing-commits/reverting-a-commit) or \`git checkout origin/main \`. Once you get those files reverted, we can continue with the review process. :octocat:` - createdComment = await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.number, - body: reviewMessage, - }) - workflowFailMessage = `${workflowFailMessage} Please see ${createdComment.data.html_url} for details.` - } catch(err) { - console.log("Error creating comment.", err) - } - core.setFailed(workflowFailMessage) + const script = require('.ci/filter-author-permissions'); + await script({github, context, core}); - name: Check Not Linted Markdown - if: ${{ always() }} + if: ${{ steps.filter_author_permissions.outcome != 'failure' }} uses: dorny/paths-filter@v4 id: filter_markdown with: @@ -66,7 +64,7 @@ jobs: name: Lint Markdown runs-on: ubuntu-latest needs: job1 - if: ${{ always() && needs.job1.outputs.markdown_change == 'true' }} + if: ${{ needs.job1.outputs.markdown_change == 'true' }} steps: - name: Checkout Code uses: actions/checkout@v6 diff --git a/.yamllint b/.yamllint new file mode 100644 index 00000000..022b9ad7 --- /dev/null +++ b/.yamllint @@ -0,0 +1,6 @@ +extends: default +rules: + line-length: + max: 120 + truthy: disable + document-start: disable diff --git a/pyproject.toml b/pyproject.toml index 59d3460f..f18c4b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ pyinstaller = "^6.19.0" pytest-cov = "^7.1.0" poethepoet = "^0.42.1" demjson3 = "^3.0.6" +yamllint = "^1.35.0" [tool.poe.tasks] cli = "python src/krux_installer.py" @@ -54,12 +55,15 @@ coverage-e2e = "pytest --cov-append --cov=src/app --cov-branch --cov-report xml coverage-drives = "pytest --cov-append --cov=src/app --cov-branch --cov-report xml ./e2e_drives" coverage = ["coverage-unit", "coverage-e2e", "coverage-drives"] +lint-yaml = "python -m yamllint -d '{extends: default, rules: {line-length: {max: 120}, truthy: disable, document-start: disable}}' .github/workflows/" + lint.sequence = [ { cmd = "jsonlint src/i18n/*.json" }, + { cmd = "yamllint .github/workflows/" }, { cmd = "pylint --rcfile=.pylint/src ./src" }, { cmd = "pylint --rcfile=.pylint/tests ./tests" }, { cmd = "pylint --rcfile=.pylint/tests ./e2e" }, - { cmd = "pylint --rcfile=.pylint/tests ./e2e_drives" }, + { cmd = "pylint --rcfile=.pylint/tests ./e2e_drives" } ] build-linux.sequence = [