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
13 changes: 10 additions & 3 deletions .github/actions/agent-event-eligibility/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,22 @@ inputs:
required: false
default: ''
expected-actions:
description: Comma-separated event action allow-list, or event names for events without an action.
description: >-
Comma-separated event action allow-list, or event names for events
without an action.
required: false
default: ''
custom-predicate:
description: JMESPath-style predicate evaluated against the event payload. Must be truthy when supplied.
description: >-
Custom predicate evaluated against the event payload. Supports payload
paths, literals, comparisons, &&/||/!, and
contains/starts_with/ends_with/length/not_null functions.
Comment thread
stranske marked this conversation as resolved.
required: false
default: ''
mode:
description: Eligibility mode. Use enforce to skip denied events, or warning to report denials without skipping.
description: >-
Eligibility mode. Use enforce to skip denied events, or warning to report
denials without skipping.
required: false
default: enforce
outputs:
Expand Down
40 changes: 40 additions & 0 deletions .github/scripts/__tests__/agents-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,46 @@ test('allows removal of allowlisted workflow paths', () => {
}
});

test('blocks consumer-only allowlisted workflow removals in Workflows repo', () => {
const result = evaluateGuard({
repository: 'stranske/Workflows',
files: [{
filename: '.github/workflows/agents-autofix-loop.yml',
status: 'removed',
}],
});

assert.equal(result.blocked, true);
assert.ok(result.fatalViolations.some((reason) => reason.includes('was deleted')));
});

test('allows consumer-only allowlisted workflow removals in consumer repos', () => {
const result = evaluateGuard({
repository: 'stranske/Template',
files: [{
filename: '.github/workflows/agents-autofix-loop.yml',
status: 'removed',
}],
});

assert.equal(result.blocked, false);
assert.equal(result.fatalViolations.length, 0);
});

test('blocks renames of allowlisted removal paths', () => {
const result = evaluateGuard({
repository: 'stranske/Template',
files: [{
filename: '.github/workflows/agents-new-entrypoint.yml',
previous_filename: '.github/workflows/agents-autofix-loop.yml',
status: 'renamed',
}],
});

assert.equal(result.blocked, true);
assert.ok(result.fatalViolations.some((reason) => reason.includes('was renamed')));
});

test('does not allow label-only bypass without codeowner approval', () => {
const result = evaluateGuard({
files: [protectedFile],
Expand Down
26 changes: 26 additions & 0 deletions .github/scripts/__tests__/detect-changes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,29 @@ test('detectChanges falls back to raw github when wrapper initialization fails',
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Failed to enable rate-limit wrapper for detect-changes/);
});

test('detectChanges preserves non-error wrapper initialization failures', async () => {
const warnings = [];
const github = {};
Object.defineProperty(github, 'request', {
get() {
throw 'string boom';
},
});
github.hook = {};

await detectChanges({
github,
core: {
warning(message) {
warnings.push(String(message));
},
setOutput() {},
},
context: { eventName: 'pull_request' },
files: ['src/app.py'],
});

assert.equal(warnings.length, 1);
assert.match(warnings[0], /string boom/);
});
101 changes: 62 additions & 39 deletions .github/scripts/agents-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,39 +10,64 @@ const path = require('path');
const DEFAULT_MARKER = '<!-- agents-guard-marker -->';

const DEFAULT_PROTECTED_PATHS = ['.github/workflows/agents-*.yml'];
const LEGACY_ALLOW_REMOVED_PATHS = [
// Keepalive consolidation retired the standalone keepalive sweeps.
'.github/workflows/agents-75-keepalive-on-gate.yml',
'.github/workflows/agents-keepalive-pr.yml',
// Issue intake now serves as the sole public entry point; the
// ChatGPT wrapper was intentionally removed.
'.github/workflows/agents-63-chatgpt-issue-sync.yml',
// Redundant issue intake workflow removed in favor of the primary entrypoint.
'.github/workflows/agents-63-issue-intake.yml',
// Clean up retired agent workflows from .github/workflows to reduce noise.
'.github/workflows/agents-64-pr-comment-commands.yml',
'.github/workflows/agents-74-pr-body-writer.yml',
// Legacy pr-meta workflows superseded by agents-pr-meta-v4.yml.
// v1 had corrupted workflow ID, v2/v3 were still running and failing.
// Archived to archives/github-actions/2025-12-02-pr-meta-legacy/
'.github/workflows/agents-pr-meta.yml',
'.github/workflows/agents-pr-meta-v2.yml',
'.github/workflows/agents-pr-meta-v3.yml',
// v1 verify-to-issue workflow deprecated; v2 is the active version.
// Archived to archives/deprecated-workflows/
'.github/workflows/agents-verify-to-issue.yml',
];

const CONSUMER_ONLY_ALLOW_REMOVED_PATHS = [
// Wave 0 cleanup removes deprecated consumer-template workflows past the
// 2026-02-15 deprecation deadline so sync PRs can delete stale copies.
'.github/workflows/agents-autofix-loop.yml',
'.github/workflows/agents-bot-comment-handler.yml',
'.github/workflows/agents-keepalive-loop.yml',
'.github/workflows/agents-verify-to-issue-v2.yml',
// The verify-to-new-pr autopilot bridge was collapsed into the main workflow.
'.github/workflows/agents-verify-to-new-pr-autopilot.yml',
];

const ALLOW_REMOVED_PATHS = new Set(
[
// Keepalive consolidation retired the standalone keepalive sweeps.
'.github/workflows/agents-75-keepalive-on-gate.yml',
'.github/workflows/agents-keepalive-pr.yml',
// Issue intake now serves as the sole public entry point; the
// ChatGPT wrapper was intentionally removed.
'.github/workflows/agents-63-chatgpt-issue-sync.yml',
// Redundant issue intake workflow removed in favor of the primary entrypoint.
'.github/workflows/agents-63-issue-intake.yml',
// Clean up retired agent workflows from .github/workflows to reduce noise.
'.github/workflows/agents-64-pr-comment-commands.yml',
'.github/workflows/agents-74-pr-body-writer.yml',
// Legacy pr-meta workflows superseded by agents-pr-meta-v4.yml.
// v1 had corrupted workflow ID, v2/v3 were still running and failing.
// Archived to archives/github-actions/2025-12-02-pr-meta-legacy/
'.github/workflows/agents-pr-meta.yml',
'.github/workflows/agents-pr-meta-v2.yml',
'.github/workflows/agents-pr-meta-v3.yml',
// v1 verify-to-issue workflow deprecated; v2 is the active version.
// Archived to archives/deprecated-workflows/
'.github/workflows/agents-verify-to-issue.yml',
// Wave 0 cleanup removes deprecated consumer-template workflows past the
// 2026-02-15 deprecation deadline so sync PRs can delete stale copies.
'.github/workflows/agents-autofix-loop.yml',
'.github/workflows/agents-bot-comment-handler.yml',
'.github/workflows/agents-keepalive-loop.yml',
'.github/workflows/agents-verify-to-issue-v2.yml',
// The verify-to-new-pr autopilot bridge was collapsed into the main workflow.
'.github/workflows/agents-verify-to-new-pr-autopilot.yml',
].map((entry) => entry.toLowerCase()),
[...LEGACY_ALLOW_REMOVED_PATHS, ...CONSUMER_ONLY_ALLOW_REMOVED_PATHS]
.map((entry) => entry.toLowerCase()),
);
const CONSUMER_ONLY_REMOVED_PATHS = new Set(
CONSUMER_ONLY_ALLOW_REMOVED_PATHS.map((entry) => entry.toLowerCase()),
);

function isConsumerOnlyRemovalAllowed(normalizedPath, repository) {
if (!CONSUMER_ONLY_REMOVED_PATHS.has(normalizedPath)) {
return true;
}
return String(repository || '').toLowerCase() !== 'stranske/workflows';
}

function isAllowlistedRemoval({ status, current = '', previous = '', repository = '' } = {}) {
if (status !== 'removed') {
return false;
}

const normalizedPath = normalizePattern(current || previous).toLowerCase();
return ALLOW_REMOVED_PATHS.has(normalizedPath) && isConsumerOnlyRemovalAllowed(normalizedPath, repository);
}

const PULL_REQUEST_TARGET_EVENT = 'pull_request_target';
const HEAD_SHA_REF_REGEX = /\bref:\s*\$\{\{\s*github\.event\.pull_request\.head\.sha\s*\}\}/i;
const SECRETS_EXPRESSION_REGEX = /\$\{\{\s*secrets\.[^}]+\}\}/i;
Expand Down Expand Up @@ -343,6 +368,7 @@ function evaluateGuard({
labelName = 'agents:allow-change',
authorLogin = '',
marker = DEFAULT_MARKER,
repository = process.env.GITHUB_REPOSITORY || '',
} = {}) {
const normalizedLabelName = String(labelName).toLowerCase();

Expand Down Expand Up @@ -397,11 +423,12 @@ function evaluateGuard({

const protectedPath = matchProtectedPath(current) || (previous ? matchProtectedPath(previous) : null);

const normalizedCurrent = normalizePattern(current).toLowerCase();
const normalizedPrevious = normalizePattern(previous).toLowerCase();
const removalAllowed =
(normalizedCurrent && ALLOW_REMOVED_PATHS.has(normalizedCurrent)) ||
(normalizedPrevious && ALLOW_REMOVED_PATHS.has(normalizedPrevious));
const removalAllowed = isAllowlistedRemoval({
status,
current,
previous,
repository,
});

if (protectedPath) {
touchedProtectedPaths.add(protectedPath);
Expand All @@ -414,10 +441,6 @@ function evaluateGuard({
}

if (status === 'renamed' && previous) {
// Allow renames/moves of files in the ALLOW_REMOVED_PATHS list
if (removalAllowed) {
continue;
}
fatalViolations.push(`• ${previous} was renamed to ${current}.`);
continue;
}
Expand Down
3 changes: 2 additions & 1 deletion .github/scripts/detect-changes.js
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,8 @@ module.exports = {
try {
github = await ensureRateLimitWrapped({ github: rawGithub, core, env: process.env });
} catch (error) {
core?.warning?.(`Failed to enable rate-limit wrapper for detect-changes: ${error.message}`);
const message = error instanceof Error ? error.message : String(error);
core?.warning?.(`Failed to enable rate-limit wrapper for detect-changes: ${message}`);
}
return detectChanges({ github, context, core, files, fetchFiles });
},
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/agents-verifier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ jobs:
}
for item in files
]
diff_surface.sort(key=lambda item: item["filename"])
diff_hash = hashlib.sha256(
json.dumps(diff_surface, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
Expand Down
9 changes: 7 additions & 2 deletions scripts/state_fingerprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,15 @@ def request(self, method: str, path: str, body: dict[str, Any] | None = None) ->
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
raise RuntimeError(f"GitHub API {method} {path} failed: {exc.code} {detail}") from exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
raise RuntimeError(f"GitHub API {method} {path} failed: {exc}") from exc

if not payload:
return None
return json.loads(payload)
try:
return json.loads(payload)
except json.JSONDecodeError as exc:
raise RuntimeError(f"GitHub API {method} {path} returned invalid JSON: {exc}") from exc

def paged_get(self, path: str) -> list[dict[str, Any]]:
page = 1
Expand Down Expand Up @@ -401,7 +406,7 @@ def main(argv: list[str] | None = None) -> int:
args = parser.parse_args(argv)
try:
return args.func(args)
except RuntimeError as exc:
except Exception as exc:
print(str(exc), file=sys.stderr)
return 1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,22 @@ inputs:
required: false
default: ''
expected-actions:
description: Comma-separated event action allow-list, or event names for events without an action.
description: >-
Comma-separated event action allow-list, or event names for events
without an action.
required: false
default: ''
custom-predicate:
description: JMESPath-style predicate evaluated against the event payload. Must be truthy when supplied.
description: >-
Custom predicate evaluated against the event payload. Supports payload
paths, literals, comparisons, &&/||/!, and
contains/starts_with/ends_with/length/not_null functions.
required: false
default: ''
mode:
description: Eligibility mode. Use enforce to skip denied events, or warning to report denials without skipping.
description: >-
Eligibility mode. Use enforce to skip denied events, or warning to report
denials without skipping.
required: false
default: enforce
outputs:
Expand Down
Loading
Loading