Skip to content

Commit 00738e8

Browse files
constkclaude
andcommitted
chore: branch-protection JSON + apply workflow + artifact cleanup + CodeQL (#14)
The JSON specs (.github/branch-protection/{develop,main}.json + README.md) already shipped in #10. This PR adds the three remaining workflows that operate against them or alongside them: - branch-protection.yml — applies the JSON spec to main + develop on schedule (Monday 06:00 UTC), workflow_dispatch, and push to main when the spec or workflow itself changes. Requires a BRANCH_PROTECTION_TOKEN secret with admin:repo scope (default GITHUB_TOKEN cannot edit branch protection on the repo it runs in). Step summary diffs before/after each apply. - artifact-cleanup.yml — weekly artifact pruning (default 7 days, scheduled live, manual dry-run by default). Stops the account-wide artifact quota from accumulating. - codeql.yml — placeholder. workflow_dispatch only until the repo is public (or gains a GHAS subscription). All `on:` triggers commented in-file with the re-activation recipe. All four workflows are EXEMPT_WORKFLOWS in check_required_contexts.py (scheduled / dispatch-only / push-to-main-only); the meta-gate stays in sync at 12 required contexts. Closes #14 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6bafc1d commit 00738e8

3 files changed

Lines changed: 270 additions & 0 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
name: Artifact cleanup
2+
3+
# Deletes GitHub Actions artifacts older than a threshold so storage doesn't
4+
# accumulate and trip the account-wide quota.
5+
#
6+
# Three triggers:
7+
# - schedule: Monday 06:00 UTC, same slot as branch-protection-sync,
8+
# security, and dependabot for weekly-governance clustering.
9+
# - workflow_dispatch: on-demand cleanup with inputs for threshold +
10+
# dry-run safety.
11+
# - push: not wired — cleanup doesn't need to fire on code changes.
12+
#
13+
# Uses the default GITHUB_TOKEN (built-in `actions: write` scope is
14+
# sufficient for artifact deletion — no PAT needed). The scheduled run
15+
# uses the hard-coded default (7 days, live delete); manual runs default
16+
# to dry-run for safety.
17+
18+
on:
19+
schedule:
20+
- cron: "0 6 * * 1"
21+
workflow_dispatch:
22+
inputs:
23+
days_to_keep:
24+
description: "Delete artifacts older than this many days"
25+
required: true
26+
default: "7"
27+
dry_run:
28+
description: "List only, no deletion (safe to flip to false when ready)"
29+
type: boolean
30+
default: true
31+
32+
permissions:
33+
actions: write # for artifact deletion
34+
contents: read
35+
36+
concurrency:
37+
group: artifact-cleanup
38+
cancel-in-progress: false
39+
40+
jobs:
41+
cleanup:
42+
name: Prune old artifacts
43+
runs-on: ubuntu-latest
44+
steps:
45+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
46+
47+
- name: Compute threshold
48+
id: threshold
49+
env:
50+
DAYS: ${{ inputs.days_to_keep || '7' }}
51+
run: |
52+
days="${DAYS}"
53+
threshold_secs=$(( days * 86400 ))
54+
echo "days=${days}" >> "$GITHUB_OUTPUT"
55+
echo "secs=${threshold_secs}" >> "$GITHUB_OUTPUT"
56+
echo "::notice::Pruning artifacts older than ${days} day(s)."
57+
58+
- name: List candidates
59+
id: list
60+
env:
61+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62+
THRESHOLD_SECS: ${{ steps.threshold.outputs.secs }}
63+
run: |
64+
gh api --paginate \
65+
"/repos/${GITHUB_REPOSITORY}/actions/artifacts?per_page=100" \
66+
--jq ".artifacts[]
67+
| select((now - (.created_at | fromdateiso8601)) > ${THRESHOLD_SECS})
68+
| [.id, .created_at,
69+
((.size_in_bytes/1024/1024*100|floor)/100|tostring),
70+
(.workflow_run.name // \"<unknown>\"),
71+
.name]
72+
| @tsv" \
73+
> candidates.tsv
74+
count=$(wc -l < candidates.tsv | tr -d ' ')
75+
echo "count=${count}" >> "$GITHUB_OUTPUT"
76+
echo "::notice::Found ${count} artifact(s) older than ${{ steps.threshold.outputs.days }} day(s)."
77+
78+
- name: Summary (candidates)
79+
if: always()
80+
run: |
81+
{
82+
echo "### Artifact cleanup — dry_run=${{ inputs.dry_run || 'false (scheduled)' }}"
83+
echo ""
84+
echo "**Threshold:** older than ${{ steps.threshold.outputs.days }} day(s)"
85+
echo "**Matched:** ${{ steps.list.outputs.count }} artifact(s)"
86+
echo ""
87+
echo "| id | created_at | size (MB) | workflow | name |"
88+
echo "|---|---|---|---|---|"
89+
awk -F'\t' '{ printf "| %s | %s | %s | %s | %s |\n", $1, $2, $3, $4, $5 }' \
90+
candidates.tsv || true
91+
} >> "$GITHUB_STEP_SUMMARY"
92+
93+
- name: Delete (skipped when dry_run=true)
94+
if: ${{ inputs.dry_run == false || inputs.dry_run == 'false' || github.event_name == 'schedule' }}
95+
env:
96+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
97+
run: |
98+
# Individual 404s are expected — an artifact can expire between the
99+
# list call and the delete call. A mixed outcome still passes. Only
100+
# the every-attempt-failed signal escalates (token lost
101+
# `actions: write`, API rate-limit, network flake).
102+
deleted=0
103+
failed=0
104+
while IFS=$'\t' read -r id _rest; do
105+
if [ -n "${id}" ]; then
106+
if gh api -X DELETE "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${id}" > /dev/null 2>&1; then
107+
deleted=$((deleted + 1))
108+
else
109+
echo "::warning::failed to delete artifact ${id}"
110+
failed=$((failed + 1))
111+
fi
112+
fi
113+
done < candidates.tsv
114+
echo "::notice::Deleted ${deleted} artifact(s); ${failed} failure(s)."
115+
echo "" >> "$GITHUB_STEP_SUMMARY"
116+
echo "**Deleted:** ${deleted} artifact(s); ${failed} failure(s)." >> "$GITHUB_STEP_SUMMARY"
117+
if [ "${failed}" -gt 0 ] && [ "${deleted}" -eq 0 ]; then
118+
echo "::error::All ${failed} delete attempts failed. Check token scope (actions: write) or API rate limits."
119+
exit 1
120+
fi
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
name: Branch protection sync
2+
3+
# Applies the declarative branch-protection configuration in
4+
# .github/branch-protection/*.json to main and develop. See that directory's
5+
# README for rationale and the trigger matrix.
6+
7+
on:
8+
schedule:
9+
# Weekly drift re-assertion, Monday 06:00 UTC. Same slot as the security
10+
# workflow and Dependabot for governance-activity clustering.
11+
- cron: "0 6 * * 1"
12+
workflow_dispatch:
13+
push:
14+
branches: [main]
15+
paths:
16+
- .github/branch-protection/**
17+
- .github/workflows/branch-protection.yml
18+
19+
permissions:
20+
contents: read
21+
22+
# Serialise concurrent applies. A scheduled run, a manual dispatch, and a
23+
# push landing around the same time could otherwise race on the same PUT.
24+
# cancel-in-progress: false — never interrupt a protection apply mid-flight.
25+
concurrency:
26+
group: branch-protection-sync
27+
cancel-in-progress: false
28+
29+
jobs:
30+
apply:
31+
name: Apply ${{ matrix.branch }} protection
32+
runs-on: ubuntu-latest
33+
strategy:
34+
fail-fast: false
35+
matrix:
36+
branch: [main, develop]
37+
steps:
38+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
39+
40+
- name: Verify token is configured
41+
env:
42+
GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }}
43+
run: |
44+
if [ -z "$GH_TOKEN" ]; then
45+
echo "::error::BRANCH_PROTECTION_TOKEN secret is not set."
46+
echo "::error::See docs/DEVELOPMENT.md#branch-protection-sync-setup."
47+
exit 1
48+
fi
49+
50+
- name: Snapshot current protection (before)
51+
env:
52+
GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }}
53+
run: |
54+
# May 404 if protection doesn't exist yet (first run). That's fine.
55+
gh api \
56+
-H "Accept: application/vnd.github+json" \
57+
"/repos/${{ github.repository }}/branches/${{ matrix.branch }}/protection" \
58+
> before.json 2>/dev/null || echo '{"note": "no protection set before run"}' > before.json
59+
60+
- name: Apply ${{ matrix.branch }} protection
61+
env:
62+
GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }}
63+
run: |
64+
gh api \
65+
--method PUT \
66+
-H "Accept: application/vnd.github+json" \
67+
-H "X-GitHub-Api-Version: 2022-11-28" \
68+
"/repos/${{ github.repository }}/branches/${{ matrix.branch }}/protection" \
69+
--input ".github/branch-protection/${{ matrix.branch }}.json"
70+
71+
- name: Snapshot current protection (after)
72+
env:
73+
GH_TOKEN: ${{ secrets.BRANCH_PROTECTION_TOKEN }}
74+
run: |
75+
gh api \
76+
-H "Accept: application/vnd.github+json" \
77+
"/repos/${{ github.repository }}/branches/${{ matrix.branch }}/protection" \
78+
> after.json
79+
80+
- name: Summary (with before → after diff)
81+
if: always()
82+
run: |
83+
{
84+
echo "### Branch protection: ${{ matrix.branch }}"
85+
echo ""
86+
echo "**Config source:** \`.github/branch-protection/${{ matrix.branch }}.json\`"
87+
echo ""
88+
echo "**Before → after diff** (empty block = no drift was present):"
89+
echo ""
90+
echo '```diff'
91+
diff -u before.json after.json || true
92+
echo '```'
93+
echo ""
94+
echo "Review current state at https://github.com/${{ github.repository }}/settings/branches"
95+
} >> "$GITHUB_STEP_SUMMARY"

.github/workflows/codeql.yml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# CodeQL — placeholder (disabled).
2+
#
3+
# CodeQL on a private personal repo requires GitHub Advanced Security (GHAS),
4+
# which is only available on Enterprise plans. When this repo either:
5+
# (a) becomes public, or
6+
# (b) gains a code-scanning subscription (GHAS or equivalent),
7+
# re-activate by replacing the `on:` block below with:
8+
#
9+
# on:
10+
# push:
11+
# branches: [main, develop]
12+
# pull_request:
13+
# branches: [main, develop]
14+
# schedule:
15+
# - cron: "0 6 * * 1"
16+
#
17+
# Until then the workflow is manual-trigger-only (workflow_dispatch) so it
18+
# does not fire on pushes/PRs and does not pollute CI with guaranteed
19+
# failures.
20+
21+
name: CodeQL
22+
23+
on:
24+
workflow_dispatch:
25+
26+
permissions:
27+
actions: read
28+
contents: read
29+
security-events: write
30+
31+
jobs:
32+
analyze:
33+
name: Analyze (${{ matrix.language }})
34+
runs-on: ubuntu-latest
35+
strategy:
36+
fail-fast: false
37+
matrix:
38+
include:
39+
- language: python
40+
build-mode: none
41+
- language: javascript-typescript
42+
build-mode: none
43+
steps:
44+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
45+
46+
- name: Initialize CodeQL
47+
uses: github/codeql-action/init@v3
48+
with:
49+
languages: ${{ matrix.language }}
50+
build-mode: ${{ matrix.build-mode }}
51+
52+
- name: Perform CodeQL Analysis
53+
uses: github/codeql-action/analyze@v3
54+
with:
55+
category: "/language:${{ matrix.language }}"

0 commit comments

Comments
 (0)