diff --git a/.github/dependabot-template.yml b/.github/dependabot-template.yml new file mode 100644 index 0000000..8989038 --- /dev/null +++ b/.github/dependabot-template.yml @@ -0,0 +1,30 @@ +# Dependabot configuration synced from amera-apps/.github. +# Do not edit in individual repos — changes will be overwritten +# by the sync_dependabot_config workflow. + +version: 2 + +registries: + codeartifact: + type: python-index + url: https://amera-artifacts-371568547021.d.codeartifact.us-east-1.amazonaws.com/pypi/amera-python/simple/ + username: aws + password: ${{ secrets.CA_TOKEN }} + +updates: + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + registries: + - codeartifact + + - package-ecosystem: docker + directory: / + schedule: + interval: weekly + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly diff --git a/.github/workflows/refresh_codeartifact_token.yml b/.github/workflows/refresh_codeartifact_token.yml new file mode 100644 index 0000000..3c21246 --- /dev/null +++ b/.github/workflows/refresh_codeartifact_token.yml @@ -0,0 +1,63 @@ +# .github/workflows/refresh_codeartifact_token.yml +# +# Rotates the AWS CodeArtifact authorization token and stores it as an +# org-level Dependabot secret. Runs every 10 hours to stay within the +# 12-hour token lifetime, ensuring Dependabot always has valid credentials +# when resolving private packages. + +name: Refresh CodeArtifact Token + +on: + schedule: + - cron: '0 */10 * * *' + workflow_dispatch: + +jobs: + refresh: + runs-on: + group: aws + steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AMERABOT_APP_ID }} + private-key: ${{ secrets.AMERABOT_APP_PRIVATE_KEY }} + owner: amera-apps + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ vars.AWS_REGION }} + + - name: Get CodeArtifact token + id: ca + shell: bash + run: | + set -euo pipefail + TOKEN="$(aws codeartifact get-authorization-token \ + --domain amera-artifacts \ + --domain-owner ${{ vars.AWS_OWNER_ID }} \ + --region "${{ vars.AWS_REGION }}" \ + --query authorizationToken --output text)" + if [[ -z "${TOKEN:-}" || "${TOKEN}" == "None" ]]; then + echo "Failed to fetch CodeArtifact token." >&2 + aws sts get-caller-identity || true + exit 1 + fi + echo "::add-mask::$TOKEN" + echo "token=$TOKEN" >> "$GITHUB_OUTPUT" + + - name: Update org Dependabot secret + shell: bash + run: | + echo "$CA_TOKEN" | gh secret set CA_TOKEN \ + --org amera-apps \ + --app dependabot \ + --visibility all + echo "Dependabot secret CA_TOKEN updated" + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + CA_TOKEN: ${{ steps.ca.outputs.token }} diff --git a/.github/workflows/sync_dependabot_config.yml b/.github/workflows/sync_dependabot_config.yml new file mode 100644 index 0000000..2bfe6c2 --- /dev/null +++ b/.github/workflows/sync_dependabot_config.yml @@ -0,0 +1,225 @@ +# .github/workflows/sync_dependabot_config.yml +# +# ❗❗ ONLY works in repos with a pyproject.toml in the root +# +# Ensures every org repo with a CodeArtifact-backed pyproject.toml has the +# correct .github/dependabot.yml. Opens a PR (rather than pushing directly) +# to comply with branch protection rules requiring review before merge. +# +# Posts a Slack summary and creates a Linear ticket listing all PRs opened. + +name: Sync Dependabot Config + +on: + schedule: + - cron: '0 11 * * *' + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.AMERABOT_APP_ID }} + private-key: ${{ secrets.AMERABOT_APP_PRIVATE_KEY }} + owner: amera-apps + + - name: Sync dependabot config to repos + id: sync + uses: actions/github-script@v7 + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const fs = require('fs') + const org = 'amera-apps' + const branch = 'chore/sync-dependabot-config' + const targetPath = '.github/dependabot.yml' + const prTitle = 'chore: sync dependabot config from org template' + + // ── Skip list ──────────────────────────────────────────────────── + // Add repo names here to exclude them from syncing, + // even if they have a CodeArtifact-backed pyproject.toml. + const skipRepos = [] + + const template = fs.readFileSync('.github/dependabot-template.yml', 'utf8') + const templateB64 = Buffer.from(template).toString('base64') + + const repos = await github.paginate(github.rest.repos.listForOrg, { + org, + type: 'all', + per_page: 100 + }) + + const opened = [] + const skipped = [] + const upToDate = [] + const errors = [] + + for (const repo of repos) { + const name = repo.name + if (repo.archived || name === '.github') continue + if (skipRepos.includes(name)) { + skipped.push(name) + continue + } + + try { + // Check for pyproject.toml with codeartifact source + let pyproject + try { + const { data } = await github.rest.repos.getContent({ + owner: org, + repo: name, + path: 'pyproject.toml' + }) + pyproject = Buffer.from(data.content, 'base64').toString('utf8') + } catch { + continue + } + + if (!pyproject.includes('codeartifact')) continue + + // Check existing dependabot.yml + let existingContent = null + try { + const { data } = await github.rest.repos.getContent({ + owner: org, + repo: name, + path: targetPath + }) + existingContent = Buffer.from(data.content, 'base64').toString('utf8') + } catch { + // File doesn't exist yet + } + + if (existingContent === template) { + upToDate.push(name) + continue + } + + // Check for an existing open PR from a previous run + const { data: existingPRs } = await github.rest.pulls.list({ + owner: org, + repo: name, + head: `${org}:${branch}`, + state: 'open' + }) + + if (existingPRs.length > 0) { + core.info(`${name}: open sync PR already exists — ${existingPRs[0].html_url}`) + continue + } + + // Get default branch SHA to branch from + const { data: refData } = await github.rest.git.getRef({ + owner: org, + repo: name, + ref: `heads/${repo.default_branch}` + }) + + // Create or update the sync branch + try { + await github.rest.git.updateRef({ + owner: org, + repo: name, + ref: `heads/${branch}`, + sha: refData.object.sha, + force: true + }) + } catch { + await github.rest.git.createRef({ + owner: org, + repo: name, + ref: `refs/heads/${branch}`, + sha: refData.object.sha + }) + } + + // Get existing file SHA on the branch (needed for update) + let fileSha = null + try { + const { data } = await github.rest.repos.getContent({ + owner: org, + repo: name, + path: targetPath, + ref: branch + }) + fileSha = data.sha + } catch { + // File doesn't exist on this branch yet + } + + await github.rest.repos.createOrUpdateFileContents({ + owner: org, + repo: name, + path: targetPath, + message: prTitle, + content: templateB64, + branch, + ...(fileSha && { sha: fileSha }) + }) + + const { data: pr } = await github.rest.pulls.create({ + owner: org, + repo: name, + title: prTitle, + body: 'Synced from the org-level [`dependabot-template.yml`](https://github.com/amera-apps/.github/blob/main/.github/dependabot-template.yml).\n\nThis configures Dependabot with CodeArtifact registry credentials and enables weekly updates for pip, Docker, and GitHub Actions ecosystems.', + head: branch, + base: repo.default_branch + }) + + opened.push({ name, url: pr.html_url }) + core.info(`${name}: opened PR — ${pr.html_url}`) + } catch (err) { + errors.push({ name, error: err.message }) + core.warning(`${name}: ${err.message}`) + } + } + + const summary = { opened, skipped, upToDate, errors } + core.setOutput('summary', JSON.stringify(summary)) + core.setOutput('opened_count', opened.length) + + core.info(`Done. Opened: ${opened.length}, Up-to-date: ${upToDate.length}, Skipped: ${skipped.length}, Errors: ${errors.length}`) + + - name: Post Slack summary + if: >- + always() + && (vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID != '') + uses: slackapi/slack-github-action@v2 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload: | + channel: ${{ vars.SLACK_PROJ_COMPLIANCE_CHANNEL_ID }} + text: "🔄 *Dependabot config sync* completed.\n${{ fromJSON(steps.sync.outputs.summary).opened.length > 0 && format('*PRs opened:*\n{0}', join(fromJSON(steps.sync.outputs.summary).opened.*.url, '\n')) || 'All repos are up to date — no PRs opened.' }}\n\n<${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|View workflow run>" + + - name: Create Linear ticket + if: >- + always() + && fromJSON(steps.sync.outputs.opened_count) > 0 + && (vars.LINEAR_AMERA_TEAM_ID != '') + run: | + SUMMARY='${{ steps.sync.outputs.summary }}' + PR_LIST=$(echo "$SUMMARY" | jq -r '.opened[] | "- [\(.name)](\(.url))"' ) + + curl -s -X POST https://api.linear.app/graphql \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "$(jq -n \ + --arg title "Dependabot config sync — ${{ fromJSON(steps.sync.outputs.opened_count) }} PRs to review" \ + --arg desc "The weekly Dependabot config sync opened PRs that need review:\n\n${PR_LIST}\n\nThese PRs sync \`.github/dependabot.yml\` from the [org template](https://github.com/amera-apps/.github/blob/main/.github/dependabot-template.yml)." \ + --arg team "$LINEAR_TEAM_ID" \ + --arg project "$LINEAR_PROJECT_ID" \ + '{ query: "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { success issue { url } } }", variables: { input: { title: $title, description: $desc, teamId: $team, projectId: $project } } }' + )" + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ vars.LINEAR_AMERA_TEAM_ID }} + LINEAR_PROJECT_ID: ${{ vars.LINEAR_SOC2_COMPLIANCE_PROJECT_ID }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19a37ba --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# OS +.DS_Store +Thumbs.db + +# Dev tooling +.cursor +.claude +claude/* +.worktrees/ diff --git a/README.md b/README.md index 792e004..1aa6736 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,174 @@ # .github -Organization-level GitHub configuration for Amera, including PR templates and contribution guidelines. +Organization-level GitHub configuration for Amera, including PR templates, reusable workflows, and Dependabot automation. + +## Dependabot Automation + +Automated vulnerability lifecycle management across all org repos, combining an [AWS Lambda webhook handler](https://github.com/amera-apps/infra/tree/main/aws/lambda/dependabot) for real-time event handling with GitHub Actions workflows for infrastructure maintenance. + +**Overview** +```mermaid +graph TD + subgraph webhook ["Org Webhook → AWS Lambda"] + GH["GitHub Events"] --> Lambda["dependabot-webhook\n(AWS Lambda)"] + Lambda -->|"dependabot_alert.created"| AlertHandler[Alert Handler] + Lambda -->|"pull_request.opened"| PRHandler[PR Handler] + Lambda -->|"pull_request.closed+merged"| MergeHandler[Merge Handler] + end + + subgraph actions ["Slack + Linear + GitHub API"] + AlertHandler -->|"post"| Slack["Slack\n(GHSA-keyed thread)"] + AlertHandler -->|"create ticket"| Linear["Linear\n(GHSA in title)"] + PRHandler -->|"reply in thread"| Slack + PRHandler -->|"comment on ticket"| Linear + PRHandler -->|"auto-merge / label"| GitHubAPI[GitHub API] + MergeHandler -->|"reply: resolved"| Slack + end + + subgraph infra [Infrastructure Workflows] + Refresh["refresh_codeartifact_token\n(every 10h)"] -->|"rotates"| CASecret["Org Dependabot secret:\nCA_TOKEN"] + Sync["sync_dependabot_config\n(daily)"] -->|"opens PRs"| DYml["dependabot.yml\n(per repo)"] + end +``` + +**Vulnerability lifecycle (detailed)** +```mermaid +graph TD + subgraph alert_phase [1 - Alert Created] + A1["dependabot_alert webhook"] --> A2["Post Slack message\n(includes GHSA ID)"] + A1 --> A3["Create Linear ticket\n(GHSA ID in title)"] + end + + subgraph pr_opened [2 - PR Opened] + B1["pull_request webhook\n(sender: dependabot)"] --> B2["Look up GHSA ID\n(via Dependabot alerts API)"] + B2 --> B3["Find Slack thread by GHSA ID"] + B3 --> B4[Reply in thread] + B2 --> B5[Find Linear ticket by GHSA ID] + B5 --> B6[Comment on ticket] + B5 --> B7["Inject 'Fixes AMR-123'\ninto PR body"] + B1 --> B8{"Update type?"} + B8 -->|"patch/minor"| B9[Enable auto-merge] + B8 -->|"major"| B10["Add 'major-update' label"] + end + + subgraph pr_merged [3 - PR Merged] + C1["pull_request webhook\n(closed+merged)"] --> C2[Reply in Slack thread: resolved] + C1 --> C3["Linear auto-closes ticket\n(via PR body keyword)"] + end + + alert_phase --> pr_opened + pr_opened --> pr_merged +``` + +### Prerequisites + +**GitHub App (AMERABOT)** — used by the Lambda for GitHub API calls (auto-merge, labels, alert lookup, PR edits) and by workflows for elevated permissions. + +1. Create a GitHub App in the `amera-apps` org with these permissions: + - **Dependabot alerts:** Read-only + - **Organization Dependabot secrets:** Read and write (for `refresh_codeartifact_token`) + - **Contents:** Read and write (for `sync_dependabot_config`) + - **Pull requests:** Read and write (for `sync_dependabot_config` and the Lambda) +2. Install it on all repos +3. Note the **installation ID** from `https://github.com/organizations/amera-apps/settings/installations` + +**Org webhook** — delivers `dependabot_alert` and `pull_request` events to the Lambda. + +1. Go to org Settings → Webhooks → Add webhook +2. Payload URL: the Lambda's function URL or API Gateway endpoint +3. Content type: `application/json` +4. Secret: a strong random string (same value stored as `GITHUB_WEBHOOK_SECRET` in the Lambda) +5. Events: select **Dependabot alerts** and **Pull requests** + +**Slack bot scopes** — `chat:write` plus `channels:history` (public) or `groups:history` (private) for thread lookup. + +**Org secrets** (for GitHub Actions workflows only): + +| Secret | Description | +|---|---| +| `AMERABOT_APP_ID` | GitHub App ID | +| `AMERABOT_APP_PRIVATE_KEY` | GitHub App private key | +| `AWS_ACCESS_KEY_ID` | IAM user for CodeArtifact token generation | +| `AWS_SECRET_ACCESS_KEY` | IAM user for CodeArtifact token generation | + +The AWS IAM user should have minimal permissions: `codeartifact:GetAuthorizationToken` and `sts:GetServiceLinkedRoleDeletionStatus`. + +**Org variables** (for GitHub Actions workflows only): + +| Variable | Description | +|---|---| +| `SLACK_PROJ_COMPLIANCE_CHANNEL_ID` | Slack channel (used by `sync_dependabot_config`) | +| `LINEAR_AMERA_TEAM_ID` | Linear team (used by `sync_dependabot_config`) | +| `LINEAR_SOC2_COMPLIANCE_PROJECT_ID` | Linear project (used by `sync_dependabot_config`) | +| `AWS_REGION` | AWS region for CodeArtifact (`us-east-1`) | +| `AWS_OWNER_ID` | AWS account ID / domain owner (`371568547021`) | + +### Dependabot Webhook Handler + +The webhook handler is deployed as an AWS Lambda. Source, configuration, and deployment instructions live in [`infra/aws/lambda/dependabot/`](https://github.com/amera-apps/infra/tree/main/aws/lambda/dependabot). + +### CodeArtifact Token Refresh + +[`.github/workflows/refresh_codeartifact_token.yml`](.github/workflows/refresh_codeartifact_token.yml) + +Dependabot needs access to the private CodeArtifact registry to resolve packages like `amera-core` and `amera-workflow`. CodeArtifact tokens expire after 12 hours, so this workflow rotates the token every 10 hours and stores it as an org-level Dependabot secret (`CA_TOKEN`). + +```mermaid +graph LR + Cron["Schedule\n(every 10h)"] --> WF[refresh_codeartifact_token] + WF -->|"AWS creds"| CA[CodeArtifact] + CA -->|"12h token"| WF + WF -->|"gh secret set"| Secret["Org Dependabot secret:\nCA_TOKEN"] + Secret -->|"read by"| DB["Dependabot\n(all repos)"] +``` + +Runs on the `aws` self-hosted runner group (AWS CLI is pre-installed). Uses `gh secret set --org --app dependabot` to update the secret without manual encryption. + +The workflow also supports `workflow_dispatch` for manual runs if a token needs immediate rotation. + +### Dependabot Config Sync + +[`.github/workflows/sync_dependabot_config.yml`](.github/workflows/sync_dependabot_config.yml) + +Dependabot requires a `.github/dependabot.yml` in each repo — there's no way to inherit it at the org level. This workflow maintains a single template ([`.github/dependabot-template.yml`](.github/dependabot-template.yml)) and syncs it to all repos that need it. + +```mermaid +graph TD + Cron["Schedule\n(daily 11:00 UTC)"] --> Sync[sync_dependabot_config] + Sync -->|"reads"| Template["dependabot-template.yml\n(this repo)"] + Sync -->|"for each repo"| Check{"Has pyproject.toml\nwith codeartifact?"} + Check -->|"yes + out of date"| PR["Open PR:\nchore/sync-dependabot-config"] + Check -->|"no or up-to-date"| Skip[Skip] + PR --> Slack["Slack summary"] + PR --> Linear["Linear ticket\n(if PRs opened)"] +``` + +**How it works:** + +1. Lists all repos in the org +2. For each non-archived repo, checks if `pyproject.toml` exists and references `codeartifact` +3. Compares the repo's `.github/dependabot.yml` to the template — skips if already matching +4. Skips if an open sync PR already exists from a previous run +5. Creates a branch, commits the template, and opens a PR +6. After processing all repos, posts a Slack summary and creates a Linear ticket listing the PRs + +PRs are opened (not direct pushes) to comply with branch protection rules requiring at least one approving review. + +#### Skipping repos + +Some repos may need a custom `dependabot.yml` or should be excluded entirely. Add them to the `skipRepos` array at the top of the `actions/github-script` block in `sync_dependabot_config.yml`: + +```javascript +const skipRepos = ['some-special-repo', 'another-exception'] +``` + +Skipped repos appear in the workflow run log for auditability. + +#### Updating the template + +To change the Dependabot config across all repos: + +1. Edit [`.github/dependabot-template.yml`](.github/dependabot-template.yml) in this repo +2. Merge to `main` +3. Wait for the next scheduled sync or trigger manually via `workflow_dispatch` +4. Review and merge the PRs opened in each repo