Skip to content
Closed
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
200 changes: 200 additions & 0 deletions .ci/filter-author-permissions.js
Original file line number Diff line number Diff line change
@@ -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<object>} 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);
}
}
40 changes: 19 additions & 21 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <file name>\`. 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:
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions .yamllint
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
extends: default
rules:
line-length:
max: 120
truthy: disable
document-start: disable
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 = [
Expand Down