From 5816476aa108eda6708874f277ed6a58d4de7a97 Mon Sep 17 00:00:00 2001 From: Anton Kaliaev Date: Wed, 26 Aug 2026 12:21:20 +0800 Subject: [PATCH 1/2] chore: add PR gate workflows and contribution policy Port Malachite-style eligibility, title/signature, and issue triage workflows, and document the no-unsolicited-PR policy for external contributors. --- .github/pull_request_template.md | 9 + .github/workflows/need-triage-label.yml | 69 +++++ .github/workflows/pr-gate.yml | 339 ++++++++++++++++++++++++ .github/workflows/pr.yml | 97 +++++++ CONTRIBUTING.md | 73 +++++ README.md | 6 +- 6 files changed, 590 insertions(+), 3 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/need-triage-label.yml create mode 100644 .github/workflows/pr-gate.yml create mode 100644 .github/workflows/pr.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..93e89245 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,9 @@ +## Summary + +## Details + +## Testing + +--- + +**Closes: #XXX** diff --git a/.github/workflows/need-triage-label.yml b/.github/workflows/need-triage-label.yml new file mode 100644 index 00000000..f183f036 --- /dev/null +++ b/.github/workflows/need-triage-label.yml @@ -0,0 +1,69 @@ +name: Auto Label Issues +on: + issues: + types: [opened] + +permissions: + issues: write + contents: read + +jobs: + triage: + if: github.repository == 'circlefin/arc-node' + runs-on: ubuntu-latest + steps: + - name: Check opener eligibility + id: check-opener + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const opener = context.payload.issue.user.login; + const association = context.payload.issue.author_association; + const allowedAssociations = ['MEMBER', 'OWNER', 'COLLABORATOR']; + + if (allowedAssociations.includes(association)) { + console.log(`Opener ${opener} has allowed association ${association}`); + core.setOutput('skip_label', 'true'); + return; + } + + let codeownerUsernames = []; + try { + const response = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/CODEOWNERS', + ref: context.payload.repository.default_branch + }); + const content = Buffer.from(response.data.content, 'base64').toString('utf-8'); + const codeowners = content.match(/@[\w-]+/g) || []; + codeownerUsernames = codeowners.map(c => c.substring(1).toLowerCase()); + } catch (error) { + console.log(`Could not read CODEOWNERS: ${error.message}`); + } + + if (codeownerUsernames.includes(opener.toLowerCase())) { + console.log(`Opener ${opener} is a codeowner`); + core.setOutput('skip_label', 'true'); + return; + } + + console.log(`Opener ${opener} needs triage label`); + core.setOutput('skip_label', 'false'); + + - name: Add need-triage label + if: steps.check-opener.outputs.skip_label == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ['need-triage'] + }); + console.log('Successfully added `need-triage` label'); + } catch (error) { + console.log('Error adding label:', error); + } diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 00000000..217f217c --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,339 @@ +# SECURITY: This workflow uses pull_request_target. Do NOT add actions/checkout +# with a PR-controlled ref: that would execute attacker code with write access +# to secrets. Only read pull_request metadata and trusted base-ref content. +name: PR Gate + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + issues: write + pull-requests: write + contents: read + +jobs: + check-eligibility: + name: Check eligibility + if: github.repository == 'circlefin/arc-node' + runs-on: ubuntu-latest + steps: + - name: Check if author is allowed bot + id: check-dependabot + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const prAuthor = context.payload.pull_request.user.login; + const allowedBots = [ + 'dependabot[bot]', + 'stepsecurity-app[bot]', + 'circle-github-action-bot', + ]; + const isAllowed = allowedBots.includes(prAuthor); + console.log(`PR author: ${prAuthor}, is allowed bot: ${isAllowed}`); + core.setOutput('is_allowed_bot', isAllowed ? 'true' : 'false'); + + - name: Check author association + id: check-association + if: steps.check-dependabot.outputs.is_allowed_bot == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const association = context.payload.pull_request.author_association; + const allowed = ['MEMBER', 'OWNER', 'COLLABORATOR']; + const isAllowed = allowed.includes(association); + console.log(`Author association: ${association}, allowed: ${isAllowed}`); + core.setOutput('is_allowed_association', isAllowed ? 'true' : 'false'); + + - name: Check if author is codeowner + id: check-codeowner + if: | + steps.check-dependabot.outputs.is_allowed_bot == 'false' && + steps.check-association.outputs.is_allowed_association == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const prAuthor = context.payload.pull_request.user.login; + + try { + const response = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/CODEOWNERS', + ref: context.payload.pull_request.base.ref + }); + + const content = Buffer.from(response.data.content, 'base64').toString('utf-8'); + // Extract usernames from CODEOWNERS (matches @username patterns) + const codeowners = content.match(/@[\w-]+/g) || []; + const codeownerUsernames = codeowners.map(c => c.substring(1).toLowerCase()); + + core.setOutput('codeowner_list', JSON.stringify(codeownerUsernames)); + + if (codeownerUsernames.includes(prAuthor.toLowerCase())) { + console.log(`${prAuthor} is a codeowner`); + core.setOutput('is_codeowner', 'true'); + } else { + console.log(`${prAuthor} is NOT a codeowner`); + core.setOutput('is_codeowner', 'false'); + } + } catch (error) { + console.log(`Could not read CODEOWNERS file: ${error.message}`); + core.setOutput('codeowner_list', '[]'); + core.setOutput('is_codeowner', 'false'); + } + + - name: Check if author is org member + id: check-org-member + if: | + steps.check-dependabot.outputs.is_allowed_bot == 'false' && + steps.check-association.outputs.is_allowed_association == 'false' && + steps.check-codeowner.outputs.is_codeowner == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const prAuthor = context.payload.pull_request.user.login; + const org = context.repo.owner; + + try { + await github.rest.orgs.checkMembershipForUser({ + org: org, + username: prAuthor + }); + // Status 204 means the user is a member + console.log(`${prAuthor} is a member of ${org}`); + core.setOutput('is_org_member', 'true'); + } catch (error) { + // Status 404 means the user is not a member (or org doesn't exist) + // Status 302 means the requester is not an org member (redirect to login) + console.log(`${prAuthor} is NOT a member of ${org}: ${error.status}`); + core.setOutput('is_org_member', 'false'); + } + + - name: Check issue assignment + id: check-assignment + if: | + steps.check-dependabot.outputs.is_allowed_bot == 'false' && + steps.check-association.outputs.is_allowed_association == 'false' && + steps.check-codeowner.outputs.is_codeowner == 'false' && + steps.check-org-member.outputs.is_org_member == 'false' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const prBody = context.payload.pull_request.body || ''; + const prAuthor = context.payload.pull_request.user.login; + + // Extract issue number from PR body + const issuePrefixes = ['closes', 'fixes', 'fix', 'close', 'resolve', 'resolves']; + const prefixPattern = issuePrefixes.join('|'); + const issueMatch = prBody.match(new RegExp(`(?:${prefixPattern}):?\\s*#(\\d+)`, 'i')); + + if (!issueMatch) { + console.log('No issue reference found in PR body'); + core.setOutput('is_assigned', 'false'); + core.setOutput('reason', 'no_issue_reference'); + return; + } + + const issueNumber = parseInt(issueMatch[1], 10); + console.log(`Found issue reference: #${issueNumber}`); + + try { + const issue = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber + }); + + // Check if PR author is assigned to the issue + const assignees = issue.data.assignees.map(a => a.login); + console.log(`Issue assignees: ${assignees.join(', ')}`); + + if (assignees.includes(prAuthor)) { + console.log(`PR author ${prAuthor} is assigned to issue #${issueNumber}`); + core.setOutput('is_assigned', 'true'); + } else { + console.log(`PR author ${prAuthor} is NOT assigned to issue #${issueNumber}`); + core.setOutput('is_assigned', 'false'); + core.setOutput('reason', 'not_assigned_to_issue'); + core.setOutput('issue_number', issueNumber.toString()); + } + } catch (error) { + console.log(`Error fetching issue #${issueNumber}: ${error.message}`); + core.setOutput('is_assigned', 'false'); + core.setOutput('reason', 'issue_not_found'); + } + + - name: Check if author is tagged by codeowner + id: check-tagged-by-codeowner + if: | + steps.check-dependabot.outputs.is_allowed_bot == 'false' && + steps.check-association.outputs.is_allowed_association == 'false' && + steps.check-codeowner.outputs.is_codeowner == 'false' && + steps.check-org-member.outputs.is_org_member == 'false' && + steps.check-assignment.outputs.is_assigned == 'false' && + steps.check-assignment.outputs.reason == 'not_assigned_to_issue' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const prAuthor = context.payload.pull_request.user.login; + const issueNumber = parseInt('${{ steps.check-assignment.outputs.issue_number }}', 10); + + const codeownerUsernames = JSON.parse('${{ steps.check-codeowner.outputs.codeowner_list }}' || '[]'); + + if (codeownerUsernames.length === 0) { + console.log('No codeowners found'); + core.setOutput('is_tagged_by_codeowner', 'false'); + return; + } + + // Get all comments on the issue + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + per_page: 100 + }); + + console.log(`Found ${comments.length} comments on issue #${issueNumber}`); + + // Look for /assign @username commands in comments from codeowners + // We need to find the latest such command to determine current assignment + const prAuthorLower = prAuthor.toLowerCase(); + const assignPattern = /\/assign\s+@([\w-]+)/gi; + + // Process comments in reverse order (newest first) to find the latest assignment + for (let i = comments.length - 1; i >= 0; i--) { + const comment = comments[i]; + const commentAuthor = comment.user.login.toLowerCase(); + + // Only consider comments from codeowners + if (!codeownerUsernames.includes(commentAuthor)) { + continue; + } + + // Find all /assign commands in this comment + const matches = [...comment.body.matchAll(assignPattern)]; + if (matches.length > 0) { + // Get the last /assign command in this comment + const lastMatch = matches[matches.length - 1]; + const assignedUser = lastMatch[1].toLowerCase(); + + console.log(`Found /assign @${lastMatch[1]} in comment by codeowner @${comment.user.login}`); + console.log(`Comment URL: ${comment.html_url}`); + + if (assignedUser === prAuthorLower) { + console.log(`PR author @${prAuthor} was assigned by codeowner`); + core.setOutput('is_tagged_by_codeowner', 'true'); + return; + } else { + console.log(`Latest /assign command is for @${lastMatch[1]}, not @${prAuthor}`); + core.setOutput('is_tagged_by_codeowner', 'false'); + return; + } + } + } + + console.log(`No /assign command from codeowner found for @${prAuthor}`); + core.setOutput('is_tagged_by_codeowner', 'false'); + } catch (error) { + console.log(`Error fetching comments for issue #${issueNumber}: ${error.message}`); + core.setOutput('is_tagged_by_codeowner', 'false'); + } + + - name: Close ineligible PR + if: | + steps.check-dependabot.outputs.is_allowed_bot == 'false' && + steps.check-association.outputs.is_allowed_association == 'false' && + steps.check-codeowner.outputs.is_codeowner == 'false' && + steps.check-org-member.outputs.is_org_member == 'false' && + steps.check-assignment.outputs.is_assigned == 'false' && + steps.check-tagged-by-codeowner.outputs.is_tagged_by_codeowner != 'true' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const reason = '${{ steps.check-assignment.outputs.reason }}'; + const issueNumber = '${{ steps.check-assignment.outputs.issue_number }}'; + const prAuthor = context.payload.pull_request.user.login; + + let message = ''; + + if (reason === 'no_issue_reference') { + message = `Hi @${prAuthor}, + + Thank you for your interest in contributing to Arc Node. + + This PR has been automatically closed because it does not reference a GitHub issue. All PRs must reference an existing issue using the format \`Closes: #XXX\`. + + **To contribute properly:** + 1. Find an existing issue you'd like to work on, or [open a new issue](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/new) describing your proposed change + 2. Comment on the issue requesting assignment and wait for maintainer approval + 3. Only submit a PR after you have been assigned to the issue + + Please see our [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for more details.`; + } else if (reason === 'not_assigned_to_issue') { + message = `Hi @${prAuthor}, + + Thank you for your interest in contributing to Arc Node. + + This PR has been automatically closed because you are not assigned to issue #${issueNumber}. We require contributors to be explicitly assigned to an issue before submitting a PR. + + **To contribute properly:** + 1. Comment on issue #${issueNumber} requesting assignment + 2. Wait for maintainer approval + 3. Only submit a PR after you have been assigned + + Please see our [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for more details.`; + } else if (reason === 'issue_not_found') { + message = `Hi @${prAuthor}, + + Thank you for your interest in contributing to Arc Node. + + This PR has been automatically closed because the referenced issue could not be found. Please ensure you reference a valid, existing issue using the format \`Closes: #XXX\`. + + **To contribute properly:** + 1. Find an existing issue you'd like to work on, or [open a new issue](https://github.com/${context.repo.owner}/${context.repo.repo}/issues/new) describing your proposed change + 2. Comment on the issue requesting assignment and wait for maintainer approval + 3. Only submit a PR after you have been assigned to the issue + + Please see our [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for more details.`; + } else { + message = `Hi @${prAuthor}, + + Thank you for your interest in contributing to Arc Node. + + This PR has been automatically closed because it does not meet our contribution requirements. + + Please see our [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/main/CONTRIBUTING.md) for details on how to contribute properly.`; + } + + // Add comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: message + }); + + // Add label + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels: ['need-triage'] + }); + } catch (error) { + console.log('Could not add label (may not exist):', error.message); + } + + // Close PR + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed' + }); + + console.log('PR closed successfully'); diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 00000000..89485318 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,97 @@ +# SECURITY: This workflow uses pull_request_target. Do NOT add actions/checkout +# with a PR-controlled ref: that would execute attacker code with write access +# to secrets. Only read pull_request metadata. +name: PR + +on: + pull_request_target: + types: + - opened + - reopened + - edited + - synchronize + +jobs: + lint: + name: Check PR title + if: github.repository == 'circlefin/arc-node' + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: step-security/action-semantic-pull-request@9142b539761b0ed6761de569f3a5020a7462a7f3 # v5.5.6 + env: + GITHUB_TOKEN: ${{ github.token }} + with: + types: | + feat + fix + chore + refactor + doc + docs + test + deps + ci + build + perf + style + revert + + verify-signatures: + name: Verify commit signatures + if: github.repository == 'circlefin/arc-node' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Check signatures and cleanup comments + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + // 1. Fetch all commits in the PR (handling pagination) + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number, + }); + + const unsigned = commits + .filter(c => !c.commit.verification.verified) + .map(c => `- \`${c.sha.substring(0, 7)}\` by **${c.commit.author.name}**`); + + // 2. Find and delete previous bot comments to avoid spam + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botCommentIdentifier = "### ⚠️ Unsigned Commits Detected"; + const previousComments = comments.filter(c => c.body.includes(botCommentIdentifier)); + + for (const comment of previousComments) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + // 3. Post new comment if unsigned commits exist + if (unsigned.length > 0) { + const body = `${botCommentIdentifier}\n\n` + + `The following commits are missing a verified signature:\n\n` + + unsigned.join('\n') + + `\n\n**How to fix:** [Sign your commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits).`; + + await github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: body + }); + + core.setFailed("Unsigned commits detected."); + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32972427..cbbd6d9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,74 @@ # Contributing to Arc Node +First, thank you for your interest in improving Arc Node! + +There are multiple opportunities to contribute at any level. It doesn't matter if you are just getting started with Rust or are the most weathered expert, we can use your help. + +No contribution is too small and all contributions are valued. + +This document will help you get started. Do not let the document intimidate you. It should be considered as a guide to help you navigate the process. + +If you contribute to this project, your contributions will be made to the project under Apache 2.0 license. + +## Code of Conduct + +The Arc Node project adheres to the [Rust Code of Conduct][rust-coc]. This code of conduct describes the minimum behavior expected from all contributors. + +## Ways to contribute + +There are three ways you can contribute to Arc Node: + +1. **By opening an issue:** For example, if you believe that you have uncovered a bug + in Arc Node, creating a [new issue][new-issue] in the issue tracker is the way to report it. +2. **By adding context:** Providing additional context to [existing issues][existing-issues], + such as screenshots and code snippets to help resolve issues. +3. **By resolving issues:** Typically this is done in the form of either + demonstrating that the issue reported is not a problem after all, or more often, + by opening a pull request that fixes the underlying problem, in a concrete and + reviewable manner. + +> [!IMPORTANT] +> Please see the [README](./README.md) for how to set up your environment, build Arc Node, and run the test suite. + +### Scope of Contributions + +At this time, we will not be accepting contributions that only fix spelling or grammatical errors in documentation, code or elsewhere. + +### Policy on Unsolicited Contributions + +We do not accept unsolicited contributions. The following types of PRs will be closed immediately: + +- PRs submitted without prior issue assignment or maintainer approval +- PRs that only fix typos, formatting, or make superficial "improvements" +- New documentation or features that were not requested +- Refactoring or "code quality improvements" that were not discussed beforehand + +Repeat offenders may be blocked from the repository. + +**How to contribute properly:** +1. Find an existing issue you'd like to work on, or open a new issue describing your proposed change +2. Comment on the issue requesting assignment and wait for maintainer approval +3. Only submit a PR after you have been assigned to the issue + +### Pull Request Requirements + +Pull requests will only be accepted if they meet **ALL** of the following criteria: + +1. The submitter must be a core contributor to Arc Node + * OR the submitter must have been explicitly assigned to the issue that the PR addresses +2. The PR must address an existing issue in our issue tracker +3. The PR description must clearly reference the issue number it resolves (for example `Closes: #XXX`) and explain how it resolves the issue +4. The PR must comply with all other contribution standards (code style, testing requirements, etc.) + +**Pull requests that do not meet these requirements will be closed without review.** + +If you are interested in contributing but are not a core contributor, please comment on an existing issue to request assignment before submitting a PR. + +### Getting Help + +If you have reviewed existing documentation and still have questions, or you +are having problems, you can get help by [opening an issue][new-issue]. + ## Working with Protocol Buffers This project uses Protocol Buffers for consensus and node communication (except consensus-critical serialization). Proto definitions are located in `crates/types/proto` and `crates/remote-signer/proto`. We use [buf](https://buf.build/) to lint, format, and check for breaking changes in our proto files. @@ -29,3 +98,7 @@ Developers may install [pre-commit](https://pre-commit.com/) hooks, which will h ```bash pre-commit install ``` + +[rust-coc]: https://rust-lang.org/policies/code-of-conduct/ +[new-issue]: https://github.com/circlefin/arc-node/issues/new +[existing-issues]: https://github.com/circlefin/arc-node/issues diff --git a/README.md b/README.md index 77c91008..10d31e14 100644 --- a/README.md +++ b/README.md @@ -176,15 +176,15 @@ For an in-depth look at system design and individual components, check out the [ ## Contributing -We welcome contributions! Please follow these steps: +We do not accept unsolicited pull requests. Please read our [Contributing Guide](CONTRIBUTING.md) before opening an issue or PR: find or open an issue, request assignment, and only submit a PR after you have been assigned. + +Once you are working on an assigned change, validate locally with: 1. **Format and lint**: `make lint` 2. **Build**: `make build` 3. **Test**: `make test-unit` 4. **Check coverage**: `make cov-show` -For more details, see our [Contributing Guide](CONTRIBUTING.md). - ## Resources - [Arc Network](https://www.arc.io/) - Official Arc Network website From c7f8846e9ad53717ebd97db41440991cb0d6435e Mon Sep 17 00:00:00 2001 From: Anton Kaliaev Date: Wed, 26 Aug 2026 12:28:18 +0800 Subject: [PATCH 2/2] be nice --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 10d31e14..41003231 100644 --- a/README.md +++ b/README.md @@ -176,9 +176,10 @@ For an in-depth look at system design and individual components, check out the [ ## Contributing -We do not accept unsolicited pull requests. Please read our [Contributing Guide](CONTRIBUTING.md) before opening an issue or PR: find or open an issue, request assignment, and only submit a PR after you have been assigned. +If you would like to contribute to the Arc Node open-source codebase, please see [CONTRIBUTING.md](./CONTRIBUTING.md). +We invite all contributors. -Once you are working on an assigned change, validate locally with: +Once you are working on a change, validate locally with: 1. **Format and lint**: `make lint` 2. **Build**: `make build`