From 2838d0fe2d2a784e15bd42575d60b1e311822628 Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Sat, 7 Nov 2026 08:56:39 +0100 Subject: [PATCH 1/3] ci(decisions): reconcile the Current Decisions pages with a scheduled job Port of chainloop-dev/platform#7115. Runs the eng-adr reconcile skill with claude-code-action on weekdays and on a manual start, and opens a draft PR when docs/decisions/ changes. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino Chainloop-Trace-Sessions: f2be09e9-dc71-42ed-9ac5-d843d16f9715 Signed-off-by: Miguel Martinez Trivino --- .github/workflows/decisions_reconcile.yml | 264 ++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 .github/workflows/decisions_reconcile.yml diff --git a/.github/workflows/decisions_reconcile.yml b/.github/workflows/decisions_reconcile.yml new file mode 100644 index 000000000..a62023ccc --- /dev/null +++ b/.github/workflows/decisions_reconcile.yml @@ -0,0 +1,264 @@ +# ============================================================================= +# 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. +# +# Required secrets: LINEAR_CLIENT_SECRET, CHAINLOOP_ENG_APP_PRIVATE_KEY +# Required variables: ANTHROPIC_FEDERATION_RULE_ID, LINEAR_CLIENT_ID +# +# Linear access uses an OAuth app with "client credentials tokens" on. Each run +# gets a token with the read scope. The token acts as the app. +# +# 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 + # 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: Prepare reconcile run + id: prepare + env: + # Client ID of the Linear OAuth app. Not a secret, but kept in a variable so that secret scanners do not flag it. + LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} + LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} + AREA: ${{ inputs.area }} + OUT_DIR: ${{ runner.temp }}/decisions + run: | + mkdir -p "$OUT_DIR" + + # Client credentials grant: a read-only token that acts as the Linear OAuth app, not as a person. + linear_token=$(curl -fsS https://api.linear.app/oauth/token \ + -u "${LINEAR_CLIENT_ID}:${LINEAR_CLIENT_SECRET}" \ + -d grant_type=client_credentials -d scope=read | jq -r .access_token) + if [ -z "$linear_token" ] || [ "$linear_token" = "null" ]; then + echo "::error::Could not get a Linear token with the client credentials grant." + exit 1 + fi + echo "::add-mask::${linear_token}" + + jq -n --arg auth "Bearer ${linear_token}" \ + '{mcpServers: {linear: {type: "http", url: "https://mcp.linear.app/mcp", headers: {Authorization: $auth}}}}' \ + > "$RUNNER_TEMP/mcp.json" + + { + 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 of the decisions with it. + GH_TOKEN: ${{ github.token }} + 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 + --mcp-config ${{ runner.temp }}/mcp.json + --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Agent,Task,mcp__linear" + + - name: Upload Claude output (failure) + # The action hides Claude's output in the log. Keep it as an artifact to find the cause of a failure. + if: ${{ failure() }} + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: claude-execution-output + path: ${{ runner.temp }}/claude-execution-output.json + retention-days: 7 + + - 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-title.txt" ] || [ ! -s "$OUT_DIR/pr-body.md" ]; then + echo "::error::The pages changed, but the run wrote no PR title or 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 + title=$(head -n1 "$OUT_DIR/pr-title.txt") + { + 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 }} + draft: true + delete-branch: true From 2e18257ec3cc0b97be0ceba613758d394d45ab0b Mon Sep 17 00:00:00 2001 From: Miguel Martinez Date: Tue, 10 Nov 2026 05:56:44 +0100 Subject: [PATCH 2/3] fix(decisions): check the sources and wait for the subagents in the reconcile job Port the fixes of the platform reconcile job. A Check sources step reads one issue and one pull request with the job token and runs the collector before Claude starts. Claude runs subagents in the foreground, writes source-error.txt when a source fails and done.txt as its last action, and Check Claude run fails on either. The Claude output is uploaded after each run. The prompt names the skill steps by title and lists which changes need a person. The parent tickets of the specs are GitHub issues, so the job no longer gets a Linear token or the Linear MCP server, and gets issues: read. Assisted-by: Claude Code Co-Authored-By: Claude Opus 5.5 Signed-off-by: Miguel Martinez Chainloop-Trace-Sessions: c9e9a4d6-7acf-4c96-a8a8-850e7f6d89aa --- .github/workflows/decisions_reconcile.yml | 124 ++++++++++++++++------ 1 file changed, 92 insertions(+), 32 deletions(-) diff --git a/.github/workflows/decisions_reconcile.yml b/.github/workflows/decisions_reconcile.yml index a62023ccc..8fe16cbc1 100644 --- a/.github/workflows/decisions_reconcile.yml +++ b/.github/workflows/decisions_reconcile.yml @@ -11,11 +11,15 @@ # 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. # -# Required secrets: LINEAR_CLIENT_SECRET, CHAINLOOP_ENG_APP_PRIVATE_KEY -# Required variables: ANTHROPIC_FEDERATION_RULE_ID, LINEAR_CLIENT_ID +# 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. # -# Linear access uses an OAuth app with "client credentials tokens" on. Each run -# gets a token with the read scope. The token acts as the app. +# 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 @@ -66,6 +70,8 @@ jobs: 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 @@ -98,48 +104,85 @@ jobs: "$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: - # Client ID of the Linear OAuth app. Not a secret, but kept in a variable so that secret scanners do not flag it. - LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }} - LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }} AREA: ${{ inputs.area }} OUT_DIR: ${{ runner.temp }}/decisions run: | mkdir -p "$OUT_DIR" - # Client credentials grant: a read-only token that acts as the Linear OAuth app, not as a person. - linear_token=$(curl -fsS https://api.linear.app/oauth/token \ - -u "${LINEAR_CLIENT_ID}:${LINEAR_CLIENT_SECRET}" \ - -d grant_type=client_credentials -d scope=read | jq -r .access_token) - if [ -z "$linear_token" ] || [ "$linear_token" = "null" ]; then - echo "::error::Could not get a Linear token with the client credentials grant." - exit 1 - fi - echo "::add-mask::${linear_token}" - - jq -n --arg auth "Bearer ${linear_token}" \ - '{mcpServers: {linear: {type: "http", url: "https://mcp.linear.app/mcp", headers: {Authorization: $auth}}}}' \ - > "$RUNNER_TEMP/mcp.json" - { echo "prompt<- --plugin-dir ${{ runner.temp }}/brain - --mcp-config ${{ runner.temp }}/mcp.json - --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Agent,Task,mcp__linear" + --allowedTools "Bash,Read,Write,Edit,Glob,Grep,Agent,Task" - - name: Upload Claude output (failure) - # The action hides Claude's output in the log. Keep it as an artifact to find the cause of a failure. - if: ${{ failure() }} + - 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: From 2a8ec10178c74d7106ca6a34fcab6d91ee9e3d8d Mon Sep 17 00:00:00 2001 From: Miguel Martinez Trivino Date: Tue, 10 Nov 2026 06:25:56 +0100 Subject: [PATCH 3/3] ci(decisions): label and date the reconcile PR Add the automated and skip-ai-session labels to the reconcile PR, as on the other automated PRs. The workflow sets the PR title to "adr: update YYYY-MM-DD" (UTC date of the run), so each run has a distinct title. Claude no longer writes the PR title. Assisted-by: Claude Code Signed-off-by: Miguel Martinez Trivino --- .github/workflows/decisions_reconcile.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/decisions_reconcile.yml b/.github/workflows/decisions_reconcile.yml index 8fe16cbc1..723f1a82d 100644 --- a/.github/workflows/decisions_reconcile.yml +++ b/.github/workflows/decisions_reconcile.yml @@ -169,8 +169,8 @@ jobs: - "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 title to ${OUT_DIR}/pr-title.txt and the PR body to ${OUT_DIR}/pr-body.md, in the - format of the "PR" step. No person read the final text, so the body lists what nobody reviewed, as + - "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. @@ -258,8 +258,8 @@ jobs: exit 0 fi - if [ ! -s "$OUT_DIR/pr-title.txt" ] || [ ! -s "$OUT_DIR/pr-body.md" ]; then - echo "::error::The pages changed, but the run wrote no PR title or body." + if [ ! -s "$OUT_DIR/pr-body.md" ]; then + echo "::error::The pages changed, but the run wrote no PR body." exit 1 fi @@ -267,7 +267,8 @@ jobs: git ls-files --others --exclude-standard -z | xargs -0 -r git add -N -- git diff > "$OUT_DIR/changes.diff" git reset -q - title=$(head -n1 "$OUT_DIR/pr-title.txt") + # 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 '@ ')" @@ -320,5 +321,8 @@ jobs: docs/decisions CLAUDE.md reviewers: ${{ steps.changes.outputs.reviewers }} + labels: | + automated + skip-ai-session draft: true delete-branch: true