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
8 changes: 8 additions & 0 deletions .github/actions/agent-run-base/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ inputs:
description: Git ref to check out for the target repository.
required: false
default: ''
target_checkout_ready:
description: >-
Set to true when the caller checked out the target repository before
loading this local action. This preserves the action directory for
post-job cleanup.
required: false
default: 'false'
workflows_app_id:
description: GitHub App ID used to mint the preferred write token.
required: false
Expand Down Expand Up @@ -119,6 +126,7 @@ runs:
printf 'Checkout auth: %s; push permitted with app token: %s.\n' "$source" "$push_allowed"

- name: Checkout
if: inputs.target_checkout_ready != 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
Expand Down
58 changes: 46 additions & 12 deletions .github/actions/path-classifier/classify.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,15 @@
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const vm = require('node:vm');

// The first delivery to an older consumer may add this contract to a base
// branch that does not contain it yet. The classifier must never execute an
// arbitrary module supplied by that PR: accept only the exact source contract
// published by Workflows. Subsequent deliveries read the trusted base copy.
const BOOTSTRAP_DELIVERY_CONTRACT_SHA256 = 'b61558cca342ffffbdfc22453e585e252a7465a2d3407020e10e4bda73065023';

const OUTPUT_NAMES = {
'docs-only': 'is-docs-only',
'python-code': 'is-python-code',
Expand Down Expand Up @@ -235,7 +242,21 @@ function compileDeliveryContract(source, filename) {
}

function readContractAtRef(ref, contractPath) {
return runGit(['show', `${ref}:${contractPath}`]);
// Keep the object bytes intact: the bootstrap allowlist digest is calculated
// from the canonical tracked file, including its trailing newline.
return readGit(['show', `${ref}:${contractPath}`]);
}

function readGit(args) {
return execFileSync('git', args, {
cwd: process.env.GITHUB_WORKSPACE || process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}

function runGit(args) {
return readGit(args).trim();
}

function isAddOnlyContractDiff(diffText, contractPath) {
Expand Down Expand Up @@ -295,6 +316,10 @@ function loadDeliveryContract(
return null;
}
const source = readBootstrapContract(headSha, relativeContractPath);
const digest = crypto.createHash('sha256').update(String(source)).digest('hex');
if (digest !== BOOTSTRAP_DELIVERY_CONTRACT_SHA256) {
return null;
}
return compileDeliveryContract(source, `${headSha}:${relativeContractPath}`);
} catch {
return null;
Expand All @@ -309,14 +334,6 @@ function loadDeliveryContract(
return require(contractPath);
}

function runGit(args) {
return execFileSync('git', args, {
cwd: process.env.GITHUB_WORKSPACE || process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
}

function tryGit(args) {
try {
return runGit(args);
Expand All @@ -339,16 +356,32 @@ function resolveBaseRef(inputBaseRef, githubContext) {
return '';
}

function fetchBaseRef(baseRef, githubContext) {
function fetchBaseRef(baseRef, githubContext, fetchGit = tryGit) {
if (!baseRef || !baseRef.startsWith('origin/')) {
return;
}
const branch = baseRef.slice('origin/'.length);
tryGit(['fetch', '--no-tags', '--depth=1', 'origin', branch]);
const refs = [branch];
const prBaseSha = githubContext.event?.pull_request?.base?.sha;
if (prBaseSha) {
tryGit(['fetch', '--no-tags', '--depth=1', 'origin', prBaseSha]);
refs.push(prBaseSha);
}
// actions/checkout uses a depth-one synthetic merge commit for pull_request
// events, so the exact head object is not guaranteed to exist locally. The
// add-only first-delivery bootstrap compares and reads that exact head.
const pullRequest = githubContext.event?.pull_request;
const prHeadSha = pullRequest?.head?.sha;
const headRepository = pullRequest?.head?.repo?.full_name || '';
const baseRepository = pullRequest?.base?.repo?.full_name || '';
if (
isStableDeliveryPullRequest(githubContext)
&& prHeadSha
&& headRepository
&& headRepository === baseRepository
) {
refs.push(prHeadSha);
}
fetchGit(['fetch', '--no-tags', '--depth=1', 'origin', ...new Set(refs)]);
}

function listChangedFiles({
Expand Down Expand Up @@ -492,6 +525,7 @@ module.exports = {
DEFAULT_CATEGORIES,
OUTPUT_NAMES,
classifyFiles,
fetchBaseRef,
globToRegExp,
isAddOnlyContractDiff,
isStableDeliveryPullRequest,
Expand Down
3 changes: 2 additions & 1 deletion .github/scripts/issue_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,8 @@ def _candidate_matches(text: str) -> list[tuple[int, int, str]]:

_EXPLICIT_CREATE_PREFIX = re.compile(
r"\b(?:create|add|introduce|scaffold|generate|write)\s+"
r"(?:(?:a|the)\s+)?(?:new\s+)?(?:files?\s+)?(?:at\s+|named\s+)?$",
r"(?:(?:a|the)\s+)?(?:new\s+)?(?:(?:files?|directories|directory|folders?)\s+)?"
r"(?:at\s+|named\s+)?$",
re.I,
)

Expand Down
29 changes: 25 additions & 4 deletions .github/scripts/keepalive_loop.js
Original file line number Diff line number Diff line change
Expand Up @@ -3225,7 +3225,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
if (migrateSummaryWriter) {
core?.info?.(
`Creating a trusted App-owned keepalive summary; existing writer ` +
`${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}.`,
`${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`,
);
}

Expand Down Expand Up @@ -4610,7 +4610,7 @@ async function updateKeepaliveLoopSummary({ github: rawGithub, context, core, in
try {
const retryWorkflowId = normalise(
inputs.retry_workflow_id ?? inputs.retryWorkflowId,
) || 'agents-keepalive-loop.yml';
) || 'agents-81-gate-followups.yml';
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
Expand Down Expand Up @@ -4779,12 +4779,33 @@ async function markAgentRunning({ github: rawGithub, context, core, inputs }) {
const stateTrace = normalise(inputs.trace || inputs.keepalive_trace || '');
const runUrl = normalise(inputs.run_url ?? inputs.runUrl);

const { state: previousState, commentId } = await loadKeepaliveState({
const {
state: previousState,
commentId,
commentAuthorLogin,
commentAuthorType,
} = await loadKeepaliveState({
github,
context,
prNumber,
trace: stateTrace,
});
const trustedSummaryAuthor = normalise(
inputs.trusted_summary_author ?? inputs.trustedSummaryAuthor,
).toLowerCase();
const existingSummaryAuthor = normalise(commentAuthorLogin).toLowerCase();
const existingSummaryAuthorType = normalise(commentAuthorType).toLowerCase();
const migrateSummaryWriter = Boolean(
commentId &&
trustedSummaryAuthor &&
(existingSummaryAuthor !== trustedSummaryAuthor || existingSummaryAuthorType !== 'bot'),
);
if (migrateSummaryWriter) {
core?.info?.(
`Creating a trusted App-owned running summary; existing writer ` +
`${existingSummaryAuthor || 'unknown'} is not ${trustedSummaryAuthor}; migrating known state.`,
);
}
const prBody = await fetchPrBody({ github, context, prNumber, core });
const focusSections = prBody ? normaliseChecklistSections(parseScopeTasksAcceptanceSections(prBody)) : {};
const focusItems = extractChecklistItems(focusSections.tasks || focusSections.acceptance || '');
Expand Down Expand Up @@ -4842,7 +4863,7 @@ async function markAgentRunning({ github: rawGithub, context, core, inputs }) {
summaryLines.push('', formatStateComment(preservedState));
const body = summaryLines.join('\n');

if (commentId) {
if (commentId && !migrateSummaryWriter) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
Expand Down
24 changes: 24 additions & 0 deletions .github/scripts/keepalive_state.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ const STATE_MARKER = 'keepalive-state';
const STATE_VERSION = 'v1';
const STATE_REGEX = /<!--\s*keepalive-state(?::([\w.-]+))?\s+(.*?)\s*-->/s;
const LOG_PREFIX = '[keepalive_state]';
const TRUSTED_KEEPALIVE_STATE_AUTHORS = new Set([
'stranske-keepalive[bot]',
'agents-workflows-bot[bot]',
// Migration-only compatibility for summaries created before dedicated App
// tokens became mandatory. New workflows do not write state with this bot.
'github-actions[bot]',
]);
const TRUSTED_KEEPALIVE_STATE_PAT_AUTHORS = new Set([
// reusable-70-orchestrator-init.yml probes and enforces these exact
// identities before ACTIONS_BOT_PAT or SERVICE_BOT_PAT may write state.
'stranske',
'stranske-automation-bot',
]);

function logInfo(message) {
console.info(`${LOG_PREFIX} ${message}`);
Expand All @@ -19,6 +32,13 @@ function normaliseLower(value) {
return normalise(value).toLowerCase();
}

function isTrustedKeepaliveStateComment(comment) {
const type = normaliseLower(comment?.user?.type);
const login = normaliseLower(comment?.user?.login);
return (type === 'bot' && TRUSTED_KEEPALIVE_STATE_AUTHORS.has(login))
|| (type === 'user' && TRUSTED_KEEPALIVE_STATE_PAT_AUTHORS.has(login));
}

function deepMerge(target, source) {
const base = target && typeof target === 'object' && !Array.isArray(target) ? { ...target } : {};
const updates = source && typeof source === 'object' && !Array.isArray(source) ? source : {};
Expand Down Expand Up @@ -241,6 +261,9 @@ async function findStateComment({ github, owner, repo, prNumber, trace }) {
let fallback = null;
for (let index = comments.length - 1; index >= 0; index -= 1) {
const comment = comments[index];
if (!isTrustedKeepaliveStateComment(comment)) {
continue;
}
const parsed = parseStateComment(comment?.body);
if (!parsed) {
continue;
Expand Down Expand Up @@ -514,4 +537,5 @@ module.exports = {
upsertStateCommentBody,
deepMerge,
formatTimestamp,
isTrustedKeepaliveStateComment,
};
Loading
Loading