diff --git a/.github/workflows/artifact-cleanup.yml b/.github/workflows/artifact-cleanup.yml new file mode 100644 index 0000000..234af34 --- /dev/null +++ b/.github/workflows/artifact-cleanup.yml @@ -0,0 +1,120 @@ +name: Artifact cleanup + +# Deletes GitHub Actions artifacts older than a threshold so storage doesn't +# accumulate and trip the account-wide quota. +# +# Three triggers: +# - schedule: Monday 06:00 UTC, same slot as branch-protection-sync, +# security, and dependabot for weekly-governance clustering. +# - workflow_dispatch: on-demand cleanup with inputs for threshold + +# dry-run safety. +# - push: not wired — cleanup doesn't need to fire on code changes. +# +# Uses the default GITHUB_TOKEN (built-in `actions: write` scope is +# sufficient for artifact deletion — no PAT needed). The scheduled run +# uses the hard-coded default (7 days, live delete); manual runs default +# to dry-run for safety. + +on: + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + days_to_keep: + description: "Delete artifacts older than this many days" + required: true + default: "7" + dry_run: + description: "List only, no deletion (safe to flip to false when ready)" + type: boolean + default: true + +permissions: + actions: write # for artifact deletion + contents: read + +concurrency: + group: artifact-cleanup + cancel-in-progress: false + +jobs: + cleanup: + name: Prune old artifacts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Compute threshold + id: threshold + env: + DAYS: ${{ inputs.days_to_keep || '7' }} + run: | + days="${DAYS}" + threshold_secs=$(( days * 86400 )) + echo "days=${days}" >> "$GITHUB_OUTPUT" + echo "secs=${threshold_secs}" >> "$GITHUB_OUTPUT" + echo "::notice::Pruning artifacts older than ${days} day(s)." + + - name: List candidates + id: list + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + THRESHOLD_SECS: ${{ steps.threshold.outputs.secs }} + run: | + gh api --paginate \ + "/repos/${GITHUB_REPOSITORY}/actions/artifacts?per_page=100" \ + --jq ".artifacts[] + | select((now - (.created_at | fromdateiso8601)) > ${THRESHOLD_SECS}) + | [.id, .created_at, + ((.size_in_bytes/1024/1024*100|floor)/100|tostring), + (.workflow_run.name // \"\"), + .name] + | @tsv" \ + > candidates.tsv + count=$(wc -l < candidates.tsv | tr -d ' ') + echo "count=${count}" >> "$GITHUB_OUTPUT" + echo "::notice::Found ${count} artifact(s) older than ${{ steps.threshold.outputs.days }} day(s)." + + - name: Summary (candidates) + if: always() + run: | + { + echo "### Artifact cleanup — dry_run=${{ inputs.dry_run || 'false (scheduled)' }}" + echo "" + echo "**Threshold:** older than ${{ steps.threshold.outputs.days }} day(s)" + echo "**Matched:** ${{ steps.list.outputs.count }} artifact(s)" + echo "" + echo "| id | created_at | size (MB) | workflow | name |" + echo "|---|---|---|---|---|" + awk -F'\t' '{ printf "| %s | %s | %s | %s | %s |\n", $1, $2, $3, $4, $5 }' \ + candidates.tsv || true + } >> "$GITHUB_STEP_SUMMARY" + + - name: Delete (skipped when dry_run=true) + if: ${{ inputs.dry_run == false || inputs.dry_run == 'false' || github.event_name == 'schedule' }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Individual 404s are expected — an artifact can expire between the + # list call and the delete call. A mixed outcome still passes. Only + # the every-attempt-failed signal escalates (token lost + # `actions: write`, API rate-limit, network flake). + deleted=0 + failed=0 + while IFS=$'\t' read -r id _rest; do + if [ -n "${id}" ]; then + if gh api -X DELETE "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}" > /dev/null 2>&1; then + deleted=$((deleted + 1)) + else + echo "::warning::failed to delete artifact ${id}" + failed=$((failed + 1)) + fi + fi + done < candidates.tsv + echo "::notice::Deleted ${deleted} artifact(s); ${failed} failure(s)." + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**Deleted:** ${deleted} artifact(s); ${failed} failure(s)." >> "$GITHUB_STEP_SUMMARY" + if [ "${failed}" -gt 0 ] && [ "${deleted}" -eq 0 ]; then + echo "::error::All ${failed} delete attempts failed. Check token scope (actions: write) or API rate limits." + exit 1 + fi diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml new file mode 100644 index 0000000..8da4bef --- /dev/null +++ b/.github/workflows/branch-protection.yml @@ -0,0 +1,95 @@ +name: Branch protection sync + +# Applies the declarative branch-protection configuration in +# .github/branch-protection/*.json to main and develop. See that directory's +# README for rationale and the trigger matrix. + +on: + schedule: + # Weekly drift re-assertion, Monday 06:00 UTC. Same slot as the security + # workflow and Dependabot for governance-activity clustering. + - cron: "0 6 * * 1" + workflow_dispatch: + push: + branches: [main] + paths: + - .github/branch-protection/** + - .github/workflows/branch-protection.yml + +permissions: + contents: read + +# Serialise concurrent applies. A scheduled run, a manual dispatch, and a +# push landing around the same time could otherwise race on the same PUT. +# cancel-in-progress: false — never interrupt a protection apply mid-flight. +concurrency: + group: branch-protection-sync + cancel-in-progress: false + +jobs: + apply: + name: Apply ${{ matrix.branch }} protection + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + branch: [main, develop] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Verify token is configured + env: + GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::error::BRANCH_PROTECTION_TOKEN secret is not set." + echo "::error::See docs/DEVELOPMENT.md#branch-protection-sync-setup." + exit 1 + fi + + - name: Snapshot current protection (before) + env: + GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }} + run: | + # May 404 if protection doesn't exist yet (first run). That's fine. + gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/branches/${{ matrix.branch }}/protection" \ + > before.json 2>/dev/null || echo '{"note": "no protection set before run"}' > before.json + + - name: Apply ${{ matrix.branch }} protection + env: + GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }} + run: | + gh api \ + --method PUT \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "/repos/${{ github.repository }}/branches/${{ matrix.branch }}/protection" \ + --input ".github/branch-protection/${{ matrix.branch }}.json" + + - name: Snapshot current protection (after) + env: + GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }} + run: | + gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/branches/${{ matrix.branch }}/protection" \ + > after.json + + - name: Summary (with before → after diff) + if: always() + run: | + { + echo "### Branch protection: ${{ matrix.branch }}" + echo "" + echo "**Config source:** \`.github/branch-protection/${{ matrix.branch }}.json\`" + echo "" + echo "**Before → after diff** (empty block = no drift was present):" + echo "" + echo '```diff' + diff -u before.json after.json || true + echo '```' + echo "" + echo "Review current state at https://github.com/${{ github.repository }}/settings/branches" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a06c133 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,55 @@ +# CodeQL — placeholder (disabled). +# +# CodeQL on a private personal repo requires GitHub Advanced Security (GHAS), +# which is only available on Enterprise plans. When this repo either: +# (a) becomes public, or +# (b) gains a code-scanning subscription (GHAS or equivalent), +# re-activate by replacing the `on:` block below with: +# +# on: +# push: +# branches: [main, develop] +# pull_request: +# branches: [main, develop] +# schedule: +# - cron: "0 6 * * 1" +# +# Until then the workflow is manual-trigger-only (workflow_dispatch) so it +# does not fire on pushes/PRs and does not pollute CI with guaranteed +# failures. + +name: CodeQL + +on: + workflow_dispatch: + +permissions: + actions: read + contents: read + security-events: write + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - language: python + build-mode: none + - language: javascript-typescript + build-mode: none + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}"