diff --git a/.github/workflows/decisions_reconcile.yml b/.github/workflows/decisions_reconcile.yml new file mode 100644 index 000000000..723f1a82d --- /dev/null +++ b/.github/workflows/decisions_reconcile.yml @@ -0,0 +1,328 @@ +# ============================================================================= +# Reconcile of the Current Decisions pages (docs/decisions/) +# ============================================================================= +# Runs the `eng-adr reconcile` skill of chainloop-dev/brain with Claude Code. +# Runs on weekdays, and on a manual start. When the pages change, it opens or updates one draft PR. +# +# The skill runs in its scheduled mode: it asks no questions, writes only the +# changes that need no person, and lists the open questions in the PR body. +# +# Claude edits files only. It gets read-only tokens. This workflow commits, +# pushes and opens the PR with the chainloop-eng GitHub App. The commits are +# signed by GitHub (sign-commits), signed off (DCO), and have an Assisted-by trailer. +# +# The parent tickets of the specs are GitHub issues of this repository. Claude +# reads them, and the pull requests, with the read-only job token. +# +# A run that cannot read a source (GitHub, the brain plugin) fails. It never +# reports "no change": "Check sources" runs before Claude starts, and +# "Check Claude run" checks the source errors and that Claude finished after. +# +# Required secrets: CHAINLOOP_ENG_APP_PRIVATE_KEY +# Required variables: ANTHROPIC_FEDERATION_RULE_ID +# +# Claude API access uses workload identity federation, not an API key. The +# federation IDs in the "Run eng-adr reconcile" step are not secrets. The +# federation rule must match only this repository on main: +# subject_prefix: repo:chainloop-dev/chainloop:ref:refs/heads/main +# claims: repository_owner=chainloop-dev, workflow=Reconcile Current Decisions +# audience: https://api.anthropic.com +# The chainloop-eng app must be installed on chainloop-dev/chainloop +# (contents and pull requests: write) and chainloop-dev/brain (contents: read). +# ============================================================================= + +name: Reconcile Current Decisions + +on: + schedule: + # 05:00 UTC, Monday to Friday. + - cron: "0 5 * * 1-5" + workflow_dispatch: + inputs: + area: + description: "Reconcile only this area. Leave blank for all areas." + required: false + type: string + dry_run: + description: "Do not open a PR. Upload the diff and the PR body as an artifact. Clear it to open the PR." + required: false + default: true + type: boolean + +permissions: {} + +concurrency: + group: decisions-reconcile + cancel-in-progress: false + +env: + # Client ID of the chainloop-eng app (chainloop-dev/brain config/release.yaml). GitHub accepts the client ID + # in place of the numeric App ID to sign the app JWT, so it goes in the app-id input. + CHAINLOOP_ENG_APP_CLIENT_ID: Iv23liu5FUQ9TuCCdxpP + PR_BRANCH: decisions-reconcile-scheduled + # A scheduled run opens a PR. A manual run opens a PR only when dry_run is cleared. + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }} + +jobs: + reconcile: + # Forks have no access to the secrets, the GitHub App or the federation rule. + if: github.repository == 'chainloop-dev/chainloop' + permissions: + contents: read + pull-requests: read + # The parent tickets of the specs. + issues: read + # GitHub OIDC token for the Anthropic workload identity federation. + id-token: write + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # The skill reads the git history (refresh commits, git log -S). + fetch-depth: 0 + persist-credentials: false + + - name: Generate brain read token + id: brain-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ env.CHAINLOOP_ENG_APP_CLIENT_ID }} + private-key: ${{ secrets.CHAINLOOP_ENG_APP_PRIVATE_KEY }} + owner: chainloop-dev + repositories: brain + permission-contents: read + + - name: Fetch brain plugin + env: + BRAIN_TOKEN: ${{ steps.brain-token.outputs.token }} + run: | + # Outside the workspace, so that the plugin files are never part of the PR. + git clone --depth 1 \ + "https://x-access-token:${BRAIN_TOKEN}@github.com/chainloop-dev/brain.git" \ + "$RUNNER_TEMP/brain" + echo "Brain commit: $(git -C "$RUNNER_TEMP/brain" rev-parse HEAD)" + + - name: Check sources + # The skill needs each of these sources. Without one, a run can report "no change" on pages that are + # out of date. This step fails the job before Claude starts. + env: + GH_TOKEN: ${{ github.token }} + BRAIN_DIR: ${{ runner.temp }}/brain + run: | + fail() { echo "::error::$1"; exit 1; } + + # GitHub. Claude, the collector and the skill read the issues and the pull requests with the job token. + issues=$(gh api "repos/${GITHUB_REPOSITORY}/issues?state=all&per_page=1" --jq 'length') || issues="" + [ "$issues" = "1" ] || fail "GitHub: the job token could not read the issues of ${GITHUB_REPOSITORY}." + echo "GitHub: read the issues of ${GITHUB_REPOSITORY}." + prs=$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=all&per_page=1" --jq 'length') || prs="" + [ "$prs" = "1" ] || fail "GitHub: the job token could not read the pull requests of ${GITHUB_REPOSITORY}." + echo "GitHub: read the pull requests of ${GITHUB_REPOSITORY}." + + # Brain plugin. + [ -f "$BRAIN_DIR/skills/eng-adr/SKILL.md" ] || fail "Brain plugin: skills/eng-adr/SKILL.md does not exist." + specs=$(python3 "$BRAIN_DIR/skills/eng-adr/tools/decisions.py" --repo "$GITHUB_WORKSPACE" \ + | jq -r '.specs | length') || specs="" + [ -n "$specs" ] || fail "Brain plugin: the collector decisions.py did not run on the checkout." + echo "Brain plugin: the collector found ${specs} specs." + + - name: Prepare reconcile run + id: prepare + env: + AREA: ${{ inputs.area }} + OUT_DIR: ${{ runner.temp }}/decisions + run: | + mkdir -p "$OUT_DIR" + + { + echo "prompt<> "$GITHUB_OUTPUT" + + - name: Run eng-adr reconcile + # The action SHA also pins the Claude Code CLI: v1.0.266 installs Claude Code 2.1.316. + # Bump the SHA deliberately, and check the CLI version in the run log. + uses: anthropics/claude-code-action@d535f5172dec02b204866b393d6b20b9b2aaf98d # v1.0.266 + env: + # Read-only. The collector looks up the PRs and the tickets of the decisions with it. + GH_TOKEN: ${{ github.token }} + # Subagents run in the foreground. A headless run ends with Claude's turn, so a background subagent's + # result is lost, and the run reports "no change". + CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" + with: + # Workload identity federation: the action exchanges the GitHub OIDC token for a short-lived + # Anthropic token. These are identifiers, not secrets. Do not also set anthropic_api_key. + # The federation rule is specific to this repository (see the header). + anthropic_federation_rule_id: ${{ vars.ANTHROPIC_FEDERATION_RULE_ID }} + anthropic_organization_id: 33e34a9b-ec6d-437d-ae3f-48eda06ae6d3 + anthropic_service_account_id: svac_01DNrcgtbfDfSTjmGkSpHGqR + anthropic_workspace_id: wrkspc_011fX5ZMoA7ZBqRG66mYGifU + # Read-only token, so the action does not mint a Claude GitHub App token with write access. + github_token: ${{ github.token }} + prompt: ${{ steps.prepare.outputs.prompt }} + # Shows Claude's final report in the job summary, also the open questions of a run that opens no PR. + display_report: true + claude_args: >- + --plugin-dir ${{ runner.temp }}/brain + --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Agent,Task" + + - name: Upload Claude output + # The action hides Claude's output in the log. Keep it after each run, to see what a run did and why. + if: ${{ always() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: claude-execution-output + path: ${{ runner.temp }}/claude-execution-output.json + if-no-files-found: warn + retention-days: 7 + + - name: Check Claude run + env: + OUT_DIR: ${{ runner.temp }}/decisions + run: | + if [ -e "$OUT_DIR/source-error.txt" ]; then + echo "::error::Claude could not read a source. The run stopped:" + cat "$OUT_DIR/source-error.txt" + exit 1 + fi + if [ ! -e "$OUT_DIR/done.txt" ]; then + echo "::error::The Claude step ended before the reconcile finished (no done.txt)." + exit 1 + fi + + - name: Check changes + id: changes + env: + OUT_DIR: ${{ runner.temp }}/decisions + run: | + changed=$(git status --porcelain --untracked-files=all | awk '{print $2}') + unexpected=$(echo "$changed" | grep -vE '^(docs/decisions/|CLAUDE\.md$)' | grep -v '^$' || true) + if [ -n "$unexpected" ]; then + echo "::error::The reconcile run changed files outside docs/decisions/ and CLAUDE.md:" + echo "$unexpected" + exit 1 + fi + + if [ -z "$changed" ]; then + echo "No change to the Current Decisions pages." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ ! -s "$OUT_DIR/pr-body.md" ]; then + echo "::error::The pages changed, but the run wrote no PR body." + exit 1 + fi + + # Include new pages: mark untracked files as intent-to-add for the diff, then unstage them again. + git ls-files --others --exclude-standard -z | xargs -0 -r git add -N -- + git diff > "$OUT_DIR/changes.diff" + git reset -q + # A dated title tells the runs apart. Each run force-updates the PR, so the date shows the latest run. + title="adr: update $(date -u +%F)" + { + echo "title=${title}" + echo "reviewers=$(paste -sd, "$OUT_DIR/reviewers.txt" 2>/dev/null | tr -d '@ ')" + # AI disclosure (AI_POLICY.md): every commit that AI produced has an Assisted-by trailer. + echo "commit-message<> "$GITHUB_OUTPUT" + echo "changed=true" >> "$GITHUB_OUTPUT" + + { + echo "## ${title}" + cat "$OUT_DIR/pr-body.md" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload result (dry run) + if: ${{ env.DRY_RUN == 'true' && steps.changes.outputs.changed == 'true' }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: decisions-reconcile + path: ${{ runner.temp }}/decisions/ + + - name: Generate repository write token + if: ${{ env.DRY_RUN != 'true' && steps.changes.outputs.changed == 'true' }} + id: write-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2 + with: + app-id: ${{ env.CHAINLOOP_ENG_APP_CLIENT_ID }} + private-key: ${{ secrets.CHAINLOOP_ENG_APP_PRIVATE_KEY }} + # Only this repository, also when the app is installed on other repositories. + repositories: chainloop + permission-contents: write + permission-pull-requests: write + + - name: Open or update PR + if: ${{ env.DRY_RUN != 'true' && steps.changes.outputs.changed == 'true' }} + # Each run starts from the default branch and force-updates PR_BRANCH, so the PR always shows the latest + # reconcile. Do not push manual fixes to that branch: answer the open questions in a separate PR. + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + with: + token: ${{ steps.write-token.outputs.token }} + branch: ${{ env.PR_BRANCH }} + title: ${{ steps.changes.outputs.title }} + body-path: ${{ runner.temp }}/decisions/pr-body.md + commit-message: ${{ steps.changes.outputs.commit-message }} + signoff: true + # The repository requires signed commits. GitHub signs the commits that the app makes with the API. + sign-commits: true + add-paths: | + docs/decisions + CLAUDE.md + reviewers: ${{ steps.changes.outputs.reviewers }} + labels: | + automated + skip-ai-session + draft: true + delete-branch: true