Claude Code can run a project skill from a checked-out repository in GitHub Actions. Anthropic's Action manual also shows a scheduled report reading commits and issues through GitHub MCP. A report used by others needs saved evidence between independent steps and a visible failure record. This guide builds one internal report with three skills: collect permitted GitHub records, draft from a saved inventory, and check the draft against that same inventory. The files below form a copyable template; BIG CHANGE has not run it against a repository or Claude account.
The big change
- What changed: The documented Action can invoke a project skill after checkout and expose structured JSON output. The template uses three separate invocations in one job, with a validated file between them.
- Why it matters: The checker sees the collector's retained fields and the draft's exact input hash. A later invocation has no assumed memory of the earlier one.
- What to watch: A passing schema and a model verdict cannot prove the report's facts. The maintainer must compare one manual run with real linked commits and issues before approving a recurring schedule.
Decide the scope before copying files
Use a repository you maintain and records you are allowed to retain in a GitHub artifact. Confirm that its Claude Code Action authentication, GitHub App or token can read repository contents and issues. Store the Claude credential as an Actions secret named ANTHROPIC_API_KEY; do not put it in the files. The example requests contents: read, issues: read, and id-token: write for the Action's documented default GitHub App authentication. Review that last permission if you select another auth route. Actions minutes and Claude API or subscription usage accrue on every run; the example's turn and job time limits bound execution, not charges.
Agree on the report's audience and rubric first. This template preserves commit metadata and open issue records, not private discussions or arbitrary code. A commit belongs to the fixed UTC interval [windowStart, windowEnd). The issue list means issues returned as open while collection ran, between collectionStart and collectionEnd. It is not an atomic issue census and says nothing about when those issues were created. If the repository's issue bodies contain content that should not be downloaded with an artifact, change the retention policy before running the job.
The checked example uses anthropics/claude-code-action@v1.0.256, the tag whose Action definition exposes conclusion and structured_output. Confirm the tag and your organization's action pinning policy when installing it. The Action manual uses mcp__github__list_commits and mcp__github__list_issues; inspect those tools in your actual connection and adjust the hook and skill together if their names or input fields differ. A tool's name in a skill does not create a permission. Claude Code's allowed-tools can grant permission, and effective managed settings can change that behavior. Review the repository, organization rules, MCP connection and read-only token scope together.
Put the three skills and controls in the repository
Copy the following files at the exact paths shown. Keep the procedures in .claude/skills/ so the Action can discover them after checkout. Each skill takes the exact absolute path of a saved file; it has no inherited conversation from the previous Action step. The workflow grants two GitHub MCP reads to the collector and local Read to the draft and checker. Those grants are permissions, not an exclusive allowlist, so inspect the effective settings. Treat any text returned by an issue or commit as source data, not as an instruction to expand access or change the workflow.
The project hook records the stage, tool and requested repository before a GitHub MCP call and rejects a call outside the allowed collector scope. Its log contains no source body or secret. A hook event shows what was requested; it does not prove that the MCP response was complete or correct. During the manual run, deliberately test a request for another repository in a safe scratch run and confirm the hook exits with a rejection. Inspect the actual connected tool names and effective permissions first. Claude Code's hook reference specifies PreToolUse, tool input and exit code 2 for blocking.
Run it once by hand
Commit the reviewed files to the repository's default branch. Leave schedule commented out in the workflow. From the repository's Actions page, start Checked Claude repository report with workflow_dispatch. Its first runner step writes a fresh run-meta.json and pending status.json, including run ID, attempt, checkout SHA, file hashes and a fixed UTC 24-hour commit window. Each Claude Action step returns a structured payload string. An ordinary runner step parses, bounds and saves the output before the next skill can read it. The runner computes file hashes in draft-input.json and check-input.json; later skills copy those values, and later gates recompute them against the saved bytes. A model's own hash calculation is not used as proof. A nonempty inventory cannot pass with an empty report. A genuinely empty inventory can pass with an explicit zero-activity result, subject to the same source-read and manual checks. The runner writes inventory.json, report.json, report.md and check.json only as their gates pass.
Open the run log and download checked-repo-report-RUNID-ATTEMPT. Compare the inventory's commit SHAs, dates, titles and links with the original GitHub commits, and inspect issue numbers, state, titles, permitted bodies and links. Confirm that the issue collection interval is displayed separately from the commit window. Read every report claim against its cited field. Accept or reject that manual report in your own review record. The model-check-passed status means the deterministic gates and checker accepted the saved inputs; it is deliberately not a human acceptance flag. If the inventory is empty, verify the successful page read and the repository and window before calling the absence of activity real.
The pagination proof in the collector output is a required assertion, not independent observation of GitHub's server state. The local gate checks page order, counts, terminal hasNext, errors and truncation, while the hook proves that both named tools were requested. The maintainer's comparison with original records remains necessary. If the MCP provider cannot expose a reliable next-page or end-of-list signal, stop the run as blocked and adapt the reader rather than declare a complete inventory. A title alone does not support a body-level claim.
Authorize recurrence and inspect its first due run
After the real manual report passes review, the maintainer chooses and authorizes the exact cron, removes the comment marks from the workflow's schedule lines, and commits the change on the default branch. GitHub runs schedules from the default branch; scheduled work may start late, and a public repository's inactive schedule can be disabled. On the first due run, open its run ID and attempt, inspect the checkout and skill hashes, reporting window, source inventory, report, check verdict and job log. Download the artifact and confirm that it is accessible before treating the output as retained. The workflow sends no email, edits no issue and pushes no report commit.
When a stage fails, read that run's log, status.json, and failure.json if present. Identify whether the Action itself failed, returned no structured output, hit a source permission or pagination limit, or produced a malformed or unsupported claim. Fix the smallest failed stage, then start a new approved manual run; do not recycle the previous run directory. If an authorized schedule keeps producing bad reports, disable that one workflow while repairing it, then re-enable it and inspect the next authorized run. GitHub's run controls cover inspection and enable/disable. The if: always() artifact step attempts to keep completed files even when a gate fails. Cancellation, runner loss or a hard timeout may stop the gate or upload; in that case the run log and GitHub run ID are the remaining evidence, and a durable artifact must not be assumed.
For a different one-off Claude Code chain, our earlier guide starts from one approved source and checks three channel drafts. The report here adds a GitHub MCP source inventory, separate commit and issue time semantics, a saved handoff between independent Action calls, and a manual-to-scheduled review step.
Copyable files
The full files follow. Keep the paths and names together. They are a documented design awaiting the reader’s real permission, tool-schema and manual-run checks.
.claude/skills/collect-repo-report/SKILL.md
---
name: collect-repo-report
description: Collect bounded GitHub commit and open-issue evidence for one approved repository report.
---
Read the absolute run-meta.json path in the invocation. Copy its runId, attempt, repository, checkoutSha, windowStart and windowEnd exactly. Use only the GitHub MCP list_commits and list_issues tools for that repository. Check the installed tool schemas before use; the official scheduled Action example exposes these two names without a separate MCP config. Request pages of at most 100 items, follow the actual next-page mechanism to a confirmed terminal page, and record every page number, item count, read outcome and hasNext value. Stop with blocked=true on an error, unknown pagination, reaching 200 retained records in either list, a needed truncated field or a permission denial. Never turn a failed read into an empty list.
Include commits dated in the fixed UTC [windowStart,windowEnd) only. Retain SHA, canonical commit URL, timestamp, title and up to 2,000 characters of complete message. List issues returned as open during collectionStart through collectionEnd; this is no atomic issue census. Retain issue number, canonical URL, open state, title and complete body up to 2,000 characters, or an empty body marked bodyComplete=false. Retain no secrets or full transcripts. Do not infer facts from missing bodies.
Return the JSON object as the string value of a single `payload` field. Its nested object has this complete shape; angle-bracket values mean copy actual values, never literal examples or invented records:
{"runId":"<run-meta.runId>","attempt":"<run-meta.attempt>","repository":"<run-meta.repository>","checkoutSha":"<run-meta.checkoutSha>","windowStart":"<run-meta.windowStart>","windowEnd":"<run-meta.windowEnd>","collectionStart":"<actual ISO UTC start>","collectionEnd":"<actual ISO UTC end>","blocked":false,"commits":[{"sha":"<40 lowercase hex>","url":"<canonical commit URL>","date":"<ISO UTC timestamp>","title":"<complete title>","message":"<complete permitted message, max 2000 chars>"}],"issues":[{"number":0,"url":"<canonical issue URL>","state":"open","title":"<complete title>","body":"<complete permitted body or empty string>","bodyComplete":true}],"pagination":{"commits":{"complete":true,"errors":0,"truncated":0,"pages":[{"number":1,"count":0,"readOk":true,"hasNext":false}]},"issues":{"complete":true,"errors":0,"truncated":0,"pages":[{"number":1,"count":0,"readOk":true,"hasNext":false}]}}}
The numeric issue `0` and page count `0` in this shape indicate types only; replace them with actual observations. Use empty record arrays only after successful completed reads. For multiple pages, each preceding hasNext must be true and only the final hasNext false. If blocked, describe the exact failure in the output and expect the runner gate to reject it. Do not write files: the runner gate alone saves the output..claude/skills/draft-repo-report/SKILL.md
---
name: draft-repo-report
description: Draft a source-linked internal report from a validated saved GitHub inventory.
---
Read the exact absolute draft-input.json path in the invocation. It is written by the ordinary runner after validating inventory.json, and gives run identity, collection interval, absolute inventoryPath and inventorySha256 calculated from saved bytes. Read that inventoryPath and use only its retained records. Copy the SHA-256 from draft-input.json; your own calculation is not the authority. Do not call GitHub MCP or use remembered facts from another session. Make at most 50 concise claims and at least one when the inventory contains a record. Every retained record need not be summarized, but no claim may reach beyond its cited field. When both source arrays are empty, set zeroActivity true and claims empty.
Return a single `payload` string containing this nested object with actual copied identity and source values:
{"runId":"<draft-input.runId>","attempt":"<draft-input.attempt>","repository":"<draft-input.repository>","checkoutSha":"<draft-input.checkoutSha>","windowStart":"<draft-input.windowStart>","windowEnd":"<draft-input.windowEnd>","collectionStart":"<draft-input.collectionStart>","collectionEnd":"<draft-input.collectionEnd>","inventorySha256":"<draft-input.inventorySha256>","zeroActivity":false,"claims":[{"kind":"commit","id":"<actual SHA>","field":"title","text":"<claim based only on this retained field>","url":"<same inventory record URL>"}]}
For an issue claim, kind is `issue`, id is the numeric issue number, and field may be a nonempty retained `title`, `body` or `state`. For a commit, field may be `title`, `message` or `date`. Issue body facts require bodyComplete true. Separate commits in UTC [windowStart,windowEnd) from issues returned open during collectionStart through collectionEnd; never call the latter an atomic snapshot. The runner verifies the copied hash, identities, fields and links independently. It renders report.md only after this gate passes..claude/skills/check-repo-report/SKILL.md
---
name: check-repo-report
description: Check every report claim against the same retained GitHub inventory without new GitHub reads.
---
Read the exact absolute check-input.json path in the invocation. The ordinary runner wrote it after validating report.json; it contains run identity, collection interval, inventoryPath, reportPath and SHA-256 hashes computed from both saved files. Read those two exact paths. Copy both hashes from check-input.json; your own calculation is not the authority. Use no GitHub MCP. Check every claim against its specific cited field and link, including scope and qualifiers; reject claims that go beyond a title or incomplete body. Check that commits and returned open issues are labeled with their distinct time semantics. Return verdict `pass` only if every claim is supported, source pages are marked complete and the report has no unsupported factual assertions. Otherwise return `blocked` with specific exceptions.
Return one `payload` string containing this nested object, with the actual IDs from every cited claim (numeric for issues, SHA string for commits):
{"runId":"<check-input.runId>","attempt":"<check-input.attempt>","repository":"<check-input.repository>","checkoutSha":"<check-input.checkoutSha>","windowStart":"<check-input.windowStart>","windowEnd":"<check-input.windowEnd>","collectionStart":"<check-input.collectionStart>","collectionEnd":"<check-input.collectionEnd>","inventorySha256":"<check-input.inventorySha256>","reportSha256":"<check-input.reportSha256>","verdict":"pass","exceptions":[],"inspectedIds":["<actual cited commit SHA or issue number>"]}
For genuinely empty inventory and report, inspectedIds is empty and a pass may be appropriate. A passing verdict does not replace maintainer comparison with the original GitHub records. The runner independently recomputes both hashes and checks identities, source references and the checker output..claude/settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "mcp__github__.*",
"hooks": [{"type":"command","command":"python3 scripts/report_hook.py"}]
}
]
}
}scripts/report_hook.py
#!/usr/bin/env python3
"""PreToolUse audit/deny for the two expected GitHub MCP reads."""
import json, os, pathlib, sys
try:
event=json.load(sys.stdin)
stage=os.environ['CLAUDE_STAGE']
repo=os.environ['GITHUB_REPOSITORY']
tool=event['tool_name']
args=event['tool_input']
run_dir=pathlib.Path(os.environ['RUN_DIR']).resolve()
owner,name=repo.split('/',1)
permitted=stage=='collect' and tool in ('mcp__github__list_commits','mcp__github__list_issues')
permitted=permitted and args.get('owner')==owner and args.get('repo')==name
permitted=permitted and run_dir.name==os.environ['GITHUB_RUN_ID']+'-'+os.environ['GITHUB_RUN_ATTEMPT']
with (run_dir/'hook-audit.jsonl').open('a',encoding='utf8') as out:
out.write(json.dumps({'event':'PreToolUse','stage':stage,'tool':tool,'repository':repo,'requestedRepository':str(args.get('owner'))+'/'+str(args.get('repo')),'runId':os.environ['GITHUB_RUN_ID'],'allowed':permitted})+'\n')
if not permitted:
print('GitHub MCP request outside the reviewed repository/stage/tool scope',file=sys.stderr)
sys.exit(2)
except Exception as exc:
print('GitHub MCP scope audit failed: '+str(exc),file=sys.stderr)
sys.exit(2)scripts/report_gate.py
#!/usr/bin/env python3
"""Bounded, fail-closed file handoff for the example report workflow."""
import hashlib, json, os, pathlib, re, sys
from datetime import datetime, timezone, timedelta
ROOT = pathlib.Path(os.environ.get('RUN_DIR', '')).resolve()
REPO = os.environ.get('GITHUB_REPOSITORY', '')
RUN = os.environ.get('GITHUB_RUN_ID', '')
ATTEMPT = os.environ.get('GITHUB_RUN_ATTEMPT', '')
SHA = os.environ.get('GITHUB_SHA', '')
MAX = 262144
def fail(stage, reason):
ROOT.mkdir(parents=True, exist_ok=True)
(ROOT / 'failure.json').write_text(json.dumps({'stage':stage,'runId':RUN,'attempt':ATTEMPT,'reason':reason},indent=2)+'\n')
(ROOT / 'status.json').write_text(json.dumps({'state':'blocked','stage':stage,'runId':RUN,'attempt':ATTEMPT})+'\n')
raise SystemExit(reason)
def read(name):
p = ROOT / name
if not p.is_file() or p.stat().st_size > MAX: raise ValueError(name+' absent or too large')
return json.loads(p.read_text())
def digest(name): return hashlib.sha256((ROOT/name).read_bytes()).hexdigest()
def iso(s): return datetime.fromisoformat(s.replace('Z','+00:00'))
def require(ok, why):
if not ok: raise ValueError(why)
def identity(x,m):
for k in ('runId','attempt','repository','checkoutSha','windowStart','windowEnd'):
require(x.get(k)==m.get(k), 'identity mismatch: '+k)
def records(x,m):
identity(x,m)
require(x.get('blocked') is False,'collector blocked')
start,end=iso(m['windowStart']),iso(m['windowEnd'])
cs,ce=iso(x['collectionStart']),iso(x['collectionEnd'])
require(start<end and cs<=ce,'bad intervals')
for kind in ('commits','issues'):
p=x['pagination'][kind]
require(type(p['errors']) is int and p['errors']>=0 and type(p['truncated']) is int and p['truncated']>=0,'bad '+kind+' counters')
require(p['complete'] is True and p['errors']==0 and p['truncated']==0,'incomplete '+kind)
pages=p['pages']
require(isinstance(pages,list) and pages and [z['number'] for z in pages]==list(range(1,len(pages)+1)), 'missing '+kind+' page')
require(all(z['readOk'] is True and type(z['count']) is int and 0<=z['count']<=100 and type(z['hasNext']) is bool for z in pages),'bad '+kind+' page fields')
require(all(z['hasNext'] is True for z in pages[:-1]) and pages[-1]['hasNext'] is False,'uncertain '+kind+' pagination')
require(sum(z['count'] for z in pages)==len(x[kind]),'page count mismatch '+kind)
require(len(x['commits'])<200 and len(x['issues'])<200,'record cap reached')
seen=set()
for c in x['commits']:
require(re.fullmatch('[0-9a-f]{40}',c['sha']) is not None,'bad commit SHA')
require(c['sha'] not in seen,'duplicate commit'); seen.add(c['sha'])
require(start<=iso(c['date'])<end,'commit outside window')
require(c['url']==f'https://github.com/{REPO}/commit/{c["sha"]}','bad commit URL')
require(0<len(c['title'])<=300 and 0<=len(c['message'])<=2000,'commit text limit')
seen=set()
for i in x['issues']:
require(type(i['number']) is int and i['number']>0 and i['number'] not in seen,'bad issue number'); seen.add(i['number'])
require(i['url']==f'https://github.com/{REPO}/issues/{i["number"]}' and i['state']=='open','bad issue link/state')
require(0<len(i['title'])<=300 and 0<=len(i['body'])<=2000,'issue text limit')
require(i['bodyComplete'] is True or i['body']=='','incomplete issue body retained')
audit=ROOT/'hook-audit.jsonl'
require(audit.is_file(),'missing hook audit')
lines=[json.loads(s) for s in audit.read_text().splitlines() if s]
for name in ('mcp__github__list_commits','mcp__github__list_issues'):
require(any(z.get('stage')=='collect' and z.get('tool')==name and z.get('repository')==REPO and z.get('requestedRepository')==REPO and z.get('allowed') is True and z.get('event')=='PreToolUse' and z.get('runId')==RUN for z in lines),'missing allowed hook observation '+name)
def handoff(stage,m,inv,report=None):
value={k:m[k] for k in ('runId','attempt','repository','checkoutSha','windowStart','windowEnd')}
value.update({'collectionStart':inv['collectionStart'],'collectionEnd':inv['collectionEnd'],'inventoryPath':str(ROOT/'inventory.json'),'inventorySha256':digest('inventory.json')})
if report is not None:
value.update({'reportPath':str(ROOT/'report.json'),'reportSha256':digest('report.json')})
name='draft-input.json' if stage=='draft' else 'check-input.json'
(ROOT/name).write_text(json.dumps(value,indent=2)+'\n')
return value
def verify_handoff(stage,m,inv,report=None):
name='draft-input.json' if stage=='draft' else 'check-input.json'
actual=read(name)
expected={k:m[k] for k in ('runId','attempt','repository','checkoutSha','windowStart','windowEnd')}
expected.update({'collectionStart':inv['collectionStart'],'collectionEnd':inv['collectionEnd'],'inventoryPath':str(ROOT/'inventory.json'),'inventorySha256':digest('inventory.json')})
if report is not None:
expected.update({'reportPath':str(ROOT/'report.json'),'reportSha256':digest('report.json')})
require(actual==expected,'stale or altered '+name)
return actual
def main(stage):
require(ROOT.name==RUN+'-'+ATTEMPT and re.fullmatch(r'\d+-\d+',ROOT.name),'unsafe run path')
if stage=='init':
ROOT.mkdir(parents=True,exist_ok=False)
end=datetime.now(timezone.utc).replace(microsecond=0)
start=end-timedelta(days=1)
workspace=pathlib.Path(os.environ['GITHUB_WORKSPACE'])
paths=['.claude/skills/collect-repo-report/SKILL.md','.claude/skills/draft-repo-report/SKILL.md','.claude/skills/check-repo-report/SKILL.md','.claude/settings.json','scripts/report_hook.py','scripts/report_gate.py']
hashes={p:hashlib.sha256((workspace/p).read_bytes()).hexdigest() for p in paths}
meta={'runId':RUN,'attempt':ATTEMPT,'repository':REPO,'checkoutSha':SHA,'windowStart':start.isoformat().replace('+00:00','Z'),'windowEnd':end.isoformat().replace('+00:00','Z'),'fileHashes':hashes}
(ROOT/'run-meta.json').write_text(json.dumps(meta,indent=2)+'\n')
(ROOT/'status.json').write_text(json.dumps({'state':'pending','runId':RUN,'attempt':ATTEMPT})+'\n')
return
m=read('run-meta.json')
require(m['runId']==RUN and m['attempt']==ATTEMPT and m['repository']==REPO and m['checkoutSha']==SHA,'environment mismatch')
workspace=pathlib.Path(os.environ['GITHUB_WORKSPACE'])
for name,expected in m['fileHashes'].items():
require(hashlib.sha256((workspace/name).read_bytes()).hexdigest()==expected,'checkout file changed: '+name)
if stage=='prepare-draft':
inv=read('inventory.json'); records(inv,m); handoff('draft',m,inv); return
if stage=='prepare-check':
inv=read('inventory.json'); records(inv,m)
report=read('report.json'); identity(report,m)
verify_handoff('draft',m,inv)
handoff('check',m,inv,report); return
require(os.environ.get('ACTION_OUTCOME')=='success' and os.environ.get('ACTION_CONCLUSION')=='success','Action failed or conclusion absent')
raw=os.environ.get('ACTION_STRUCTURED_OUTPUT','')
require(0<len(raw)<=MAX,'structured output absent or too large')
wrapper=json.loads(raw)
require(set(wrapper)=={'payload'} and isinstance(wrapper['payload'],str),'wrong output wrapper')
x=json.loads(wrapper['payload'])
require(type(x) is dict,'payload must be an object')
identity(x,m)
if stage=='collect':
records(x,m)
(ROOT/'inventory.json').write_text(json.dumps(x,indent=2)+'\n')
elif stage=='draft':
inv=read('inventory.json'); records(inv,m)
prepared=verify_handoff('draft',m,inv)
require(x.get('inventorySha256')==prepared['inventorySha256'],'inventory hash mismatch')
require(x.get('collectionStart')==inv['collectionStart'] and x.get('collectionEnd')==inv['collectionEnd'],'collection interval mismatch')
allowed={('commit',v['sha']):v for v in inv['commits']}|{('issue',v['number']):v for v in inv['issues']}
claims=x.get('claims')
require(isinstance(claims,list) and len(claims)<=50,'bad claim count')
require(len(claims)>0 or (len(inv['commits'])==0 and len(inv['issues'])==0 and x.get('zeroActivity') is True),'nonempty source with empty report')
require(x.get('zeroActivity') is (len(inv['commits'])==0 and len(inv['issues'])==0),'incorrect zero-activity flag')
for c in claims:
require(0<len(c['text'])<=500 and c['field'] in ('title','message','body','date','state'),'bad claim')
source=allowed.get((c['kind'],c['id']))
require(source is not None and source['url']==c['url'],'unknown source')
require(c['field'] in source and bool(source[c['field']]),'missing source field')
require(c['field']!='body' or source['bodyComplete'] is True,'truncated body claim')
(ROOT/'report.json').write_text(json.dumps(x,indent=2)+'\n')
heading=f"# Repository report: {REPO}\n\nCommits in UTC [{m['windowStart']}, {m['windowEnd']}); open issues returned during {inv['collectionStart']} to {inv['collectionEnd']} (not an atomic snapshot).\n\n"
detail='\n'.join('- '+c['text']+' ([source]('+c['url']+'))' for c in claims) if claims else 'Zero activity: the complete reads returned no commits in this window and no issues as open during collection.'
text=heading+detail+'\n'
(ROOT/'report.md').write_text(text)
elif stage=='check':
inv=read('inventory.json'); records(inv,m)
report=read('report.json'); identity(report,m)
prepared=verify_handoff('check',m,inv,report)
require(x.get('inventorySha256')==prepared['inventorySha256'] and x.get('reportSha256')==prepared['reportSha256'],'input hash mismatch')
require(x.get('verdict')=='pass' and x.get('exceptions')==[],'checker blocked')
refs={str(c['id']) for c in report['claims']}
require(set(map(str,x.get('inspectedIds',[])))==refs,'checker did not inspect each cited ID')
require(x.get('collectionStart')==inv['collectionStart'] and x.get('collectionEnd')==inv['collectionEnd'],'collection interval mismatch')
(ROOT/'check.json').write_text(json.dumps(x,indent=2)+'\n')
(ROOT/'status.json').write_text(json.dumps({'state':'model-check-passed','runId':RUN,'attempt':ATTEMPT,'manualSourceReviewRequired':True},indent=2)+'\n')
else: raise ValueError('unknown stage')
if __name__=='__main__':
stage=sys.argv[1] if len(sys.argv)==2 else 'unknown'
try: main(stage)
except (Exception, KeyError) as e: fail(stage,str(e)).github/workflows/claude-repo-report.yml
name: Checked Claude repository report
on:
workflow_dispatch:
# Add schedule only after an actual manual report has been checked and the maintainer approves this cron.
# schedule:
# - cron: '17 9 * * 1'
permissions:
contents: read
issues: read
id-token: write # Claude GitHub App authentication; review if using a different auth route.
concurrency:
group: checked-repo-report
cancel-in-progress: false
jobs:
report:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
RUN_DIR: ${{ github.workspace }}/run-output/${{ github.run_id }}-${{ github.run_attempt }}
steps:
- uses: actions/checkout@v4
- name: Bind this attempt and reporting window
run: python3 scripts/report_gate.py init
- name: Collect through GitHub MCP
id: collect
continue-on-error: true
env:
CLAUDE_STAGE: collect
uses: anthropics/claude-code-action@v1.0.256
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
settings: .claude/settings.json
prompt: /collect-repo-report ${{ env.RUN_DIR }}/run-meta.json
claude_args: >-
--max-turns 20
--allowedTools "Read,mcp__github__list_commits,mcp__github__list_issues"
--json-schema '{"type":"object","properties":{"payload":{"type":"string"}},"required":["payload"],"additionalProperties":false}'
- name: Check and save inventory
id: collect_gate
if: always()
env:
ACTION_OUTCOME: ${{ steps.collect.outcome }}
ACTION_CONCLUSION: ${{ steps.collect.outputs.conclusion }}
ACTION_STRUCTURED_OUTPUT: ${{ steps.collect.outputs.structured_output }}
run: python3 scripts/report_gate.py collect
- name: Bind draft input to saved bytes
id: draft_input
if: steps.collect_gate.outcome == 'success'
run: python3 scripts/report_gate.py prepare-draft
- name: Draft from saved inventory
id: draft
if: steps.draft_input.outcome == 'success'
continue-on-error: true
env:
CLAUDE_STAGE: draft
uses: anthropics/claude-code-action@v1.0.256
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
settings: .claude/settings.json
prompt: /draft-repo-report ${{ env.RUN_DIR }}/draft-input.json
claude_args: >-
--max-turns 12 --allowedTools "Read"
--json-schema '{"type":"object","properties":{"payload":{"type":"string"}},"required":["payload"],"additionalProperties":false}'
- name: Check and save draft
id: draft_gate
if: always() && steps.draft_input.outcome == 'success'
env:
ACTION_OUTCOME: ${{ steps.draft.outcome }}
ACTION_CONCLUSION: ${{ steps.draft.outputs.conclusion }}
ACTION_STRUCTURED_OUTPUT: ${{ steps.draft.outputs.structured_output }}
run: python3 scripts/report_gate.py draft
- name: Bind checker input to saved bytes
id: check_input
if: steps.draft_gate.outcome == 'success'
run: python3 scripts/report_gate.py prepare-check
- name: Check claims against saved evidence
id: check
if: steps.check_input.outcome == 'success'
continue-on-error: true
env:
CLAUDE_STAGE: check
uses: anthropics/claude-code-action@v1.0.256
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
settings: .claude/settings.json
prompt: /check-repo-report ${{ env.RUN_DIR }}/check-input.json
claude_args: >-
--max-turns 12 --allowedTools "Read"
--json-schema '{"type":"object","properties":{"payload":{"type":"string"}},"required":["payload"],"additionalProperties":false}'
- name: Check and save verdict
id: check_gate
if: always() && steps.check_input.outcome == 'success'
env:
ACTION_OUTCOME: ${{ steps.check.outcome }}
ACTION_CONCLUSION: ${{ steps.check.outputs.conclusion }}
ACTION_STRUCTURED_OUTPUT: ${{ steps.check.outputs.structured_output }}
run: python3 scripts/report_gate.py check
- name: Attempt to retain this run's files
id: upload
if: always()
uses: actions/upload-artifact@v4
with:
name: checked-repo-report-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ env.RUN_DIR }}/
if-no-files-found: error
retention-days: 14
- name: Show retained artifact receipt
if: always() && steps.upload.outcome == 'success'
env:
ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }}
ARTIFACT_URL: ${{ steps.upload.outputs.artifact-url }}
run: |
test -n "$ARTIFACT_ID" && test -n "$ARTIFACT_URL"
printf 'Artifact ID: %s\nArtifact URL: %s\n' "$ARTIFACT_ID" "$ARTIFACT_URL"Sources & further reading
- Claude Code GitHub Actions manual: checkout, project skill invocation, scheduled MCP example, authentication and usage limits.
- Claude Code skills, hooks, permissions, MCP, and headless mode: skill discovery, tool grants, hook behavior, connection checks and noninteractive limits.
- Tagged Action definition and structured output example: pinned inputs and outputs used by the template.
- GitHub workflow schedule, workflow artifacts, and upload-artifact: scheduling and attempted output retention.



