Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions .github/workflows/decisions_reconcile.yml
Original file line number Diff line number Diff line change
@@ -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 }}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The token steps pass a GitHub App client ID through app-id, which expects the numeric App ID; token generation will fail before the brain or repository token is available. Pass this value through client-id in both steps, or configure the numeric App ID.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/decisions_reconcile.yml, line 83:

<comment>The token steps pass a GitHub App client ID through `app-id`, which expects the numeric App ID; token generation will fail before the brain or repository token is available. Pass this value through `client-id` in both steps, or configure the numeric App ID.</comment>

<file context>
@@ -0,0 +1,260 @@
+        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
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changed. GitHub accepts the client ID in place of the numeric App ID as the JWT issuer, and the pinned action passes the app-id value through to it. The same step with the same value succeeds in chainloop-dev/platform (run 42352738620, step "Generate brain read token"). The workflow now has a comment that says this.

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 \
Comment thread
migmartri marked this conversation as resolved.
Resolved
"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<<PROMPT_EOF"
cat <<EOF
/br:eng-adr reconcile ${AREA}

This is an unattended run in GitHub Actions. Follow the scheduled-run rules of the skill: ask no questions,
write the changes that need no person, and list each open question in the PR body.

These changes need no person. Write them:
- Each result of "Re-check of a page entry" in the format doc that asks nobody. For example, a
"not confirmed" entry whose behavior the code check finds now becomes "verified" and moves to
Current decisions.
- A new reason on a Non-Confirmed entry whose ticket changed state.
- A new spec decision that both workers of "Worth an entry" pass. Its code state gives its section.
A decision that both workers fail goes on the "Not shown" line.
- A replacement that the replacement rules of the format doc settle with explicit evidence.
- The deciders from the collector, new IDs from next_ids, and the refresh line.
- A correction of "Check the written text" that does not change the meaning.
- The block of "Agent instructions".

These changes need a person. Do not write them. List each one as an open question in the PR body:
- A BORDER verdict of "Worth an entry".
- "A current decision that no longer holds".
- A decision that nothing in the repository can show ("user confirmed").
- A change to the area map, for example an area for a spec in unmapped_specs.
- A change to the Language.
- A correction of "Check the written text" that changes the meaning.
- A conflict, and code that goes against a "Not shown" decision.

This workflow owns git and the PR. Change these steps of the skill:
- "Branch" and "Before you commit": stay on the current checkout of the default branch. Do not create or
switch branches. Do not commit, push, or open, edit or comment on a PR.
- "Show the user": there is no person to show the diff to. Do not file tickets.
- "PR": write the PR body to ${OUT_DIR}/pr-body.md, in the format of the "PR" step. Do not write a PR
title: this workflow sets it. No person read the final text, so the body lists what nobody reviewed, as
that step says. Write the GitHub handles of the reviewers of the "PR" step to
${OUT_DIR}/reviewers.txt, one per line.
When nothing changed, write none of these files.

Sources: when a tool call to GitHub or the collector fails or is refused, stop. Do not continue with
less data, and do not report "no change". Write the tool, the call, and the error to
${OUT_DIR}/source-error.txt.

Finish: the run ends when your turn ends. Wait for the result of each subagent before you end your turn.
As your last action, after the pages are written or you found that nothing changed, write
${OUT_DIR}/done.txt.

Change only files under docs/decisions/ and the repository agent instructions file (CLAUDE.md).
EOF
echo "PROMPT_EOF"
} >> "$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<<COMMIT_EOF"
printf '%s\n\nAssisted-by: Claude Code\n' "$title"
echo "COMMIT_EOF"
} >> "$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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved

- 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
Loading