Skip to content
Merged
Show file tree
Hide file tree
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
79 changes: 79 additions & 0 deletions .github/scripts/__tests__/keepalive-loop.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,49 @@ test('updateKeepaliveLoopSummary migrates legacy state to the selected App write
assert.equal(github.actions.some((action) => action.commentId === 44), false);
});

test('updateKeepaliveLoopSummary reuses a summary written by the selected PAT user', async () => {
const existingState = [
'<!-- keepalive-loop-summary -->',
formatStateComment({
trace: 'pat-summary-trace',
iteration: 1,
max_iterations: 5,
failure_threshold: 3,
}),
].join('\n');
const github = buildGithubStub({
comments: [{
id: 46,
body: existingState,
html_url: 'https://example.com/46',
user: { login: 'stranske-automation-bot', type: 'User' },
}],
});

await updateKeepaliveLoopSummary({
github,
context: buildContext(123),
core: buildCore(),
inputs: {
prNumber: 123,
action: 'run',
runResult: 'success',
gateConclusion: 'success',
tasksTotal: 2,
tasksUnchecked: 1,
keepaliveEnabled: true,
iteration: 1,
maxIterations: 5,
failureThreshold: 3,
trace: 'pat-summary-trace',
trusted_summary_author: 'stranske-automation-bot',
},
});

assert.equal(github.actions[0].type, 'update');
assert.equal(github.actions[0].commentId, 46);
});

test('updateKeepaliveLoopSummary recovers trusted state past an untrusted summary writer', async () => {
const trustedState = [
'<!-- keepalive-loop-summary -->',
Expand Down Expand Up @@ -4930,6 +4973,42 @@ test('markAgentRunning migrates legacy state to the selected App writer', async
assert.equal(persistedState.running, true);
});

test('markAgentRunning reuses a running summary written by the selected PAT user', async () => {
const existingStateBody = formatStateComment({
trace: 'running-pat-trace',
iteration: 2,
tasks: { total: 4, unchecked: 3 },
});
const github = buildGithubStub({
comments: [{
id: 202,
body: `<!-- keepalive-loop-summary -->\n## Summary\n${existingStateBody}`,
html_url: 'https://example.com/202',
user: { login: 'stranske-automation-bot', type: 'User' },
}],
});

await markAgentRunning({
github,
context: { repo: { owner: 'test', repo: 'repo' } },
core: buildCore(),
inputs: {
pr_number: 44,
agent_type: 'codex',
iteration: 2,
max_iterations: 5,
tasks_total: 4,
tasks_unchecked: 3,
trace: 'running-pat-trace',
trusted_summary_author: 'stranske-automation-bot',
},
});

assert.equal(github.actions.length, 1);
assert.equal(github.actions[0].type, 'update');
assert.equal(github.actions[0].commentId, 202);
});

test('markAgentRunning creates comment when none exists', async () => {
const github = buildGithubStub({ comments: [] });
const inputs = {
Expand Down
25 changes: 25 additions & 0 deletions .github/scripts/__tests__/sync_pr_merge_contract.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ test('maint71 run writes reports and records a no-PR result with fake action cli
CLEANUP_BRANCHES_INPUT: process.env.CLEANUP_BRANCHES_INPUT,
DRY_RUN_INPUT: process.env.DRY_RUN_INPUT,
AUTO_MERGE_INPUT: process.env.AUTO_MERGE_INPUT,
OWNER_PR_PAT: process.env.OWNER_PR_PAT,
SYNC_PR_MERGE_REPORT_JSON: process.env.SYNC_PR_MERGE_REPORT_JSON,
};
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'maint71-run-'));
Expand Down Expand Up @@ -298,6 +299,7 @@ test('maint71 run writes reports and records a no-PR result with fake action cli
process.env.CLEANUP_BRANCHES_INPUT = 'false';
process.env.DRY_RUN_INPUT = 'true';
process.env.AUTO_MERGE_INPUT = 'false';
process.env.OWNER_PR_PAT = 'test-owner-token';
process.env.SYNC_PR_MERGE_REPORT_JSON = reportPath;

await run({
Expand Down Expand Up @@ -330,6 +332,27 @@ test('maint71 run writes reports and records a no-PR result with fake action cli
}
});

test('maint71 fails closed before cross-repository API calls without OWNER_PR_PAT', async () => {
const originalOwnerPat = process.env.OWNER_PR_PAT;
try {
delete process.env.OWNER_PR_PAT;
await assert.rejects(
run({
github: {},
core: { warning: () => {} },
context: {
repo: { owner: 'stranske', repo: 'Workflows' },
payload: {},
},
}),
/Maint 71 requires OWNER_PR_PAT/,
);
} finally {
if (originalOwnerPat === undefined) delete process.env.OWNER_PR_PAT;
else process.env.OWNER_PR_PAT = originalOwnerPat;
}
});

test('maint71 recovers exact-head evidence from an already-merged candidate PR', async () => {
const originalCwd = process.cwd();
const envKeys = [
Expand All @@ -339,6 +362,7 @@ test('maint71 recovers exact-head evidence from an already-merged candidate PR',
'AUTO_MERGE_INPUT',
'EVIDENCE_ONLY_INPUT',
'ACTIVE_SYNC_HASH_INPUT',
'OWNER_PR_PAT',
'CONSUMER_SYNC_CANARIES_PATH',
'TRUSTED_SYNC_ACTORS',
'SYNC_PR_MERGE_REPORT_JSON',
Expand Down Expand Up @@ -452,6 +476,7 @@ test('maint71 recovers exact-head evidence from an already-merged candidate PR',
process.env.DRY_RUN_INPUT = 'true';
process.env.AUTO_MERGE_INPUT = 'false';
process.env.ACTIVE_SYNC_HASH_INPUT = 'candidate';
process.env.OWNER_PR_PAT = 'test-owner-token';
process.env.EVIDENCE_ONLY_INPUT = 'true';
process.env.CONSUMER_SYNC_CANARIES_PATH = canaryConfigPath;
process.env.TRUSTED_SYNC_ACTORS = 'stranske';
Expand Down
20 changes: 16 additions & 4 deletions .github/scripts/keepalive_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ function normalise(value) {
return String(value ?? '').trim();
}

function expectedTrustedSummaryAuthorType(author) {
return normalise(author).toLowerCase().endsWith('[bot]') ? 'bot' : 'user';
}

function buildAuthorityChallengeEvidence({
agentSummary,
summaryReason,
Expand Down Expand Up @@ -3217,14 +3221,18 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
).toLowerCase();
const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase();
const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase();
const trustedSummaryAuthorType = expectedTrustedSummaryAuthorType(trustedSummaryAuthor);
const migrateSummaryWriter = Boolean(
commentId &&
trustedSummaryAuthor &&
(existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'),
(
existingSummaryAuthor !== trustedSummaryAuthor ||
existingSummaryAuthorType !== trustedSummaryAuthorType
),
);
if (migrateSummaryWriter) {
core?.info?.(
`Creating a trusted App-owned keepalive summary; existing writer ` +
`Creating a trusted keepalive summary; existing writer ` +
`${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`,
);
}
Expand Down Expand Up @@ -4795,14 +4803,18 @@ async function markAgentRunning({ github: rawGithub, context, core, inputs }) {
).toLowerCase();
const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase();
const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase();
const trustedSummaryAuthorType = expectedTrustedSummaryAuthorType(trustedSummaryAuthor);
const migrateSummaryWriter = Boolean(
commentId &&
trustedSummaryAuthor &&
(existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'),
(
existingSummaryAuthor !== trustedSummaryAuthor ||
existingSummaryAuthorType !== trustedSummaryAuthorType
),
);
if (migrateSummaryWriter) {
core?.info?.(
`Creating a trusted App-owned running summary; existing writer ` +
`Creating a trusted running summary; existing writer ` +
`${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`,
);
}
Expand Down
29 changes: 14 additions & 15 deletions .github/scripts/maint71_merge_sync_prs.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,21 +344,20 @@ async function run({ github, context, core }) {
paginateWithRetry: (githubInstance, method, params) =>
githubInstance.paginate(method, params),
};
const { createTokenAwareRetry } = retryHelpers;
const { withRetry } = createTokenAwareRetry
? await createTokenAwareRetry({
github,
core,
env: process.env,
task: 'maint-71-merge-sync-prs',
capabilities: [
'pull-requests:read',
'pull-requests:write',
'checks:read',
'contents:write',
],
})
: { github, withRetry: (fn) => fn(github) };
if (!String(process.env.OWNER_PR_PAT || '').trim()) {
throw new Error(
'Maint 71 requires OWNER_PR_PAT for fail-closed cross-repository PR mutations',
);
Comment thread
stranske marked this conversation as resolved.
}
// The actions/github-script client is authenticated with OWNER_PR_PAT by
// the workflow. Keep retries on that exact client so credential or quota
// degradation cannot rotate a cross-repository mutation to a token with a
// smaller repository installation scope.
const withRetry = (fn, options = {}) => retryHelpers.withRetry(fn, {
github,
core,
...options,
});
async function getRequiredContexts({ owner, repo, branch }) {
try {
const { data: protection } = await withRetry((client) =>
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/maint-71-merge-sync-prs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ jobs:
SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-review-resolution-report.json
REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }}
with:
github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }}
github-token: ${{ secrets.OWNER_PR_PAT }}
script: |
const { run } = require("./.github/scripts/maint71_merge_sync_prs.js");
await run({ github, context, core });
Expand All @@ -225,7 +225,7 @@ jobs:
SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-pr-premerge-report.json
REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }}
with:
github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }}
github-token: ${{ secrets.OWNER_PR_PAT }}
script: |
const { run } = require("./.github/scripts/maint71_merge_sync_prs.js");
await run({ github, context, core });
Expand Down Expand Up @@ -307,7 +307,7 @@ jobs:
SYNC_PR_MERGE_REPORT_JSON: artifacts/sync-pr-merge-report.json
REGISTERED_REPOS_INPUT: ${{ steps.repos.outputs.list }}
with:
github-token: ${{ secrets.OWNER_PR_PAT || secrets.SERVICE_BOT_PAT || github.token }}
github-token: ${{ secrets.OWNER_PR_PAT }}
script: |
const { run } = require("./.github/scripts/maint71_merge_sync_prs.js");
await run({ github, context, core });
Expand Down
2 changes: 1 addition & 1 deletion docs/INTEGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ backward compatibility, but new manual setup should prefer the current defaults.
|--------|---------|--------------|
| `SERVICE_BOT_PAT` | Bot account for comments/labels (stranske-automation-bot); fallback credential for cross-repository label sync | agents, autofix, Maint 69 label sync |
| `ACTIONS_BOT_PAT` | Workflow dispatch triggers | event-hub/follow-up automation (`agents-80-pr-event-hub.yml`, `agents-81-gate-followups.yml`); legacy orchestrator/pr-meta only when intentionally retained |
| `OWNER_PR_PAT` | Create PRs on behalf of user; preferred credential for cross-repository label sync | issue-intake, Maint 69 label sync |
| `OWNER_PR_PAT` | Create PRs on behalf of user; required owner-scoped credential for generated-delivery mutations | issue-intake, Maint 69 label sync, Maint 71 sync reconciliation |
| `CROSS_REPO_TOKEN` | Read access to a dependency repository for cross-repo smoke | `cross-repo-smoke.yml` when `CROSS_REPO_SMOKE_ENABLED=true` |

### Cross-Repo Smoke (opt-in)
Expand Down
2 changes: 1 addition & 1 deletion docs/WORKFLOW_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ _Inline Gate helper_
- **`maint-69-sync-labels.yml`** — Propagates the canonical `.github/labels-core.yml` set to every registered consumer repo (or a provided subset), reusing the registered-repo helper + load-balanced API client without any additional App-token minting.
- **`maint-70-fix-integration-formatting.yml`** — Manual formatter for Workflows-Integration-Tests that resolves the repo default branch, applies `black`+`ruff` fixes, and pushes via PAT only when a token is available; runs read-only otherwise.
- **`maint-71-auto-fix-integration.yml`** — Auto-triggered integration fixer that watches “Integration CI failed” issues/comments, re-runs the formatting routine, and pushes via PAT when available (otherwise posting a skipped note).
- **`maint-71-merge-sync-prs.yml`** — Scans each registered non-admin consumer repo for generated deliveries, closes stale duplicates, deletes leftover branches, and is the only lane allowed to merge a stable consumer delivery. Stable PRs advance `staging` → `reviewing` → `sealed`: one substantive configured-reviewer response is sufficient after seven minutes, all-capacity-unavailable responses may degrade after that quiet period, and zero response degrades after fifteen minutes. A status that explicitly says the review was skipped, excluded, review-disabled, or not performed is unavailable evidence rather than a response; a completed negative verdict with substantive output is still a response. None of those fallbacks waives an active non-outdated review thread. A specific thread may be resolved only from an authenticated exact-head proof that names the merged Workflows source fix and whose source commit is contained in the delivery. The exact sealed head must be validly GitHub-signed and then pass a freshly triggered Gate before merge; unsigned or non-GitHub-signed heads are returned to Maint 68 for replacement. The staging label remains a shared merger hold until Maint 71 completes the merge. Candidate evidence is persisted and validated before the irreversible candidate merge; once every candidate is merged or safely recovered, Maint 71 dispatches `phase=promote` with that exact evidence. A workflow-sync selector ignores sibling dev-tool PRs when deciding whether its expected stable branch exists, so independent lanes do not create false `target_missing` failures. Required checks come from legacy branch protection when visible, otherwise active repository and inherited organization rulesets.
- **`maint-71-merge-sync-prs.yml`** — Scans each registered non-admin consumer repo for generated deliveries, closes stale duplicates, deletes leftover branches, and is the only lane allowed to merge a stable consumer delivery. Its cross-repository mutations require `OWNER_PR_PAT`; the workflow fails closed rather than rotating to a partial-scope service or default token. Stable PRs advance `staging` → `reviewing` → `sealed`: one substantive configured-reviewer response is sufficient after seven minutes, all-capacity-unavailable responses may degrade after that quiet period, and zero response degrades after fifteen minutes. A status that explicitly says the review was skipped, excluded, review-disabled, or not performed is unavailable evidence rather than a response; a completed negative verdict with substantive output is still a response. None of those fallbacks waives an active non-outdated review thread. A specific thread may be resolved only from an authenticated exact-head proof that names the merged Workflows source fix and whose source commit is contained in the delivery. The exact sealed head must be validly GitHub-signed and then pass a freshly triggered Gate before merge; unsigned or non-GitHub-signed heads are returned to Maint 68 for replacement. The staging label remains a shared merger hold until Maint 71 completes the merge. Candidate evidence is persisted and validated before the irreversible candidate merge; once every candidate is merged or safely recovered, Maint 71 dispatches `phase=promote` with that exact evidence. A workflow-sync selector ignores sibling dev-tool PRs when deciding whether its expected stable branch exists, so independent lanes do not create false `target_missing` failures. Required checks come from legacy branch protection when visible, otherwise active repository and inherited organization rulesets.
- **`maint-72-fix-pr-body-conflicts.yml`** — Periodically removes stray `pr_body.md` files from consumer repos and ensures `.gitignore` blocks them, reusing the registered-repo helper + PAT discovery so cleanups only run when push access is available.
- **`maint-74-ledger-base-sync.yml`** — Keeps `.agents` ledger base entries aligned with the repo’s default branch by running `scripts/ledger_migrate_base.py` and opening a helper PR (no extra App token mint needed).
- **`maint-auto-update-pypi-versions.yml`** — Monday 03:00 UTC canonical source lane that batches routine PyPI pin updates into one mutable source PR; an explicit security override may run outside that window.
Expand Down
Loading
Loading