Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions .changeset/minor-custom-safe-output-issue-api.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions .github/aw/safe-outputs-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ The `report-incomplete` safe-output is enabled by default and is distinct from `

Post-processing GitHub Actions jobs registered as MCP tools. Agents call the tool by its normalized name (dashes → underscores, e.g., `send_notification`). The job runs after the agent completes with access to `$GH_AW_AGENT_OUTPUT` (agent output JSON path). Use to integrate Slack, Discord, external APIs, databases, or any service requiring secrets. Import from shared files via `imports:`.

Custom implementations should add a generated-by footer and workflow provenance annotations to published content; direct API calls omit them and lose provenance-based tracking. For GitHub issues, run the setup action, initialize `actions/setup/js/index.cjs` with `setupGlobals`, and call `createIssue`. See the [public issue-creation API](https://github.github.com/gh-aw/reference/custom-safe-outputs/#issue-creation-api).

Two extra per-job properties:
- `needs:` (string or array) - sequence this job relative to other compiled jobs; valid targets are `agent`, `safe_outputs`, `detection`, `upload_assets`, `unlock`, or another job name (dashes normalized to underscores). Persists across recompiles, unlike hand-patching the lock file.
- `artifacts:` (array of strings) - filesystem paths/globs under `/tmp/gh-aw/` that this job's `inputs` reference so the compiler includes them in the unified agent artifact upload (paths the agent wrote at runtime are otherwise dropped). Directory entries must end with `/`; only secret-redaction-covered extensions (`.txt`, `.json`, `.log`, `.md`, `.mdx`, `.yml`, `.jsonl`, `.patch`) are persisted, and `..` or hidden-file segments are rejected at compile time.
Expand Down
1 change: 1 addition & 0 deletions .github/aw/shared-safe-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Use this pattern when a workflow must perform a controlled write action after th
- Validate required fields on every matching item.
- Respect staged mode by checking `GH_AW_SAFE_OUTPUTS_STAGED === 'true'`.
- Preview in staged mode instead of performing the real side effect.
- Include a generated-by footer and workflow provenance annotations on published content so tracking is preserved. For GitHub issues, use the [stable issue-creation API](https://github.github.com/gh-aw/reference/custom-safe-outputs/#issue-creation-api).
- Use warnings for skippable invalid items and fail the job only for fatal errors.
- If a step reads an agent-written file outside the compiler's built-in known paths, declare it under `artifacts:` (array of `/tmp/gh-aw/`-rooted paths — files, directories, or globs). Undeclared paths are silently dropped and never reach the job.

Expand Down
12 changes: 7 additions & 5 deletions .github/scripts/aw_issue_clustering_publish.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ async function ensureLabel(github, owner, repo, staged, core) {
else await github.rest.issues.createLabel({ owner, repo, name: label, color: "1D76DB", description: "Essential AW-generated issue clusters: assign one to resolve related findings" });
}

async function reconcile(github, owner, repo, clusters, corpus, staged, core) {
async function reconcile(github, owner, repo, clusters, corpus, staged, core, createIssue) {
const existing = new Map(corpus.managed.map(item => [metadata(item).key, item]));
const wanted = new Set(clusters.map(cluster => cluster.key));
const retiring = [...existing].filter(([key]) => !wanted.has(key)).map(([, item]) => item);
Expand Down Expand Up @@ -379,8 +379,10 @@ async function reconcile(github, owner, repo, clusters, corpus, staged, core) {
} else if (!staged) {
const live = await collectIssues(github, owner, repo);
requireValid(live.filter(issue => issue.state === "open" && managed(issue)).length < 10, "Active queue is full; refusing an eleventh issue");
const created = await github.rest.issues.create({ owner, repo, title, body: `${content}\n\n${ownerMarker}\n`, labels: [label, "automation", "agentic-workflows", "cookie"] });
url = created.data.html_url;
requireValid(typeof createIssue === "function", "Attributed issue-creation API is required");
const created = await createIssue({ owner, repo, title, body: content, labels: [label, "automation", "agentic-workflows", "cookie"] });
requireValid(!created.staged, "Issue creation was unexpectedly staged during live reconciliation");
url = created.issue.html_url;
}
result.push([rank, cluster, url]);
core.info(`${staged ? "Preview: " : ""}${title}: ${url || ""}`);
Expand Down Expand Up @@ -524,7 +526,7 @@ async function publishDashboard(github, owner, repo, body, staged, core) {
}
}

async function publish({ github, context, core }) {
async function publish({ github, context, core, createIssue }) {
const { owner, repo } = context.repo;
const output = JSON.parse(fs.readFileSync(process.env.GH_AW_AGENT_OUTPUT, "utf8"));
const items = (output.items || []).filter(item => item.type === "publish_essential_issues");
Expand All @@ -550,7 +552,7 @@ async function publish({ github, context, core }) {
requireValid(dashboardBody(preview, refreshed, corpus, context.runId, corpus.cleanup, staged).length < 60000, "Dashboard body too large");
const cleanup = await cleanupCompleted(github, owner, repo, corpus, staged, core);
await ensureLabel(github, owner, repo, staged, core);
const result = await reconcile(github, owner, repo, clusters, corpus, staged, core);
const result = await reconcile(github, owner, repo, clusters, corpus, staged, core, createIssue);
const body = dashboardBody(result, refreshed, corpus, context.runId, cleanup, staged);
await publishDashboard(github, owner, repo, body, staged, core);
await core.summary.addRaw(body).write();
Expand Down
59 changes: 58 additions & 1 deletion .github/scripts/test_aw_issue_clustering_publish.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,59 @@ test("live queue refuses an eleventh issue", async () => {
await assert.rejects(reconcile(github, "github", "gh-aw", [cluster()], data, false, { info() {} }), /eleventh/);
});

test("new summaries use attributed issue creation and preserve managed metadata", async () => {
const { setupGlobals, createIssue } = require("../../actions/setup/js/index.cjs");
const data = corpus();
const calls = [];
const github = {
hook: { before() {} },
paginate: async () => [],
rest: {
issues: {
listForRepo() {},
async create(parameters) {
calls.push(parameters);
return { data: issue(20, parameters) };
},
},
},
};
const env = {
GH_AW_WORKFLOW_NAME: "AW Essential Issue Clustering",
GH_AW_WORKFLOW_ID: "aw-issue-clustering",
GH_AW_CALLER_WORKFLOW_ID: "github/gh-aw/aw-issue-clustering",
GH_AW_PROMPTS_DIR: path.resolve(__dirname, "../../actions/setup/md"),
GH_AW_SAFE_OUTPUTS_STAGED: "false",
};
const previousEnv = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]]));
const globalKeys = ["core", "github", "context", "exec", "io", "getOctokit", "runtimeFeatures", "hasRuntimeFeature", "getRuntimeFeatureValue"];
const previousGlobals = Object.fromEntries(globalKeys.map(key => [key, global[key]]));
const core = { info() {}, debug() {}, warning() {}, error() {} };
Object.assign(process.env, env);
try {
setupGlobals(core, github, { repo: { owner: "github", repo: "gh-aw" }, runId: 123, payload: {} }, {}, {}, () => github);
const result = await reconcile(github, "github", "gh-aw", [cluster()], data, false, core, createIssue);
assert.equal(calls.length, 1);
assert.ok(calls[0].body.includes(island(cluster(), 1, repo)));
assert.match(calls[0].body, /> Generated by \[AW Essential Issue Clustering\]/);
assert.match(calls[0].body, /gh-aw-agentic-workflow: AW Essential Issue Clustering/);
assert.ok(calls[0].body.includes("https://github.com/github/gh-aw/actions/runs/123"));
assert.equal(calls[0].title, "[AW Top 10] 01 Repair shared ledger persistence");
assert.deepEqual(calls[0].labels, ["aw-essential", "automation", "agentic-workflows", "cookie"]);
assert.equal(result[0][2], `https://github.com/${repo}/issues/20`);
assert.equal(buildCorpus(repo, [issue(20, calls[0])], [], cutoff).managed.length, 1);
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
for (const [key, value] of Object.entries(previousGlobals)) {
if (value === undefined) delete global[key];
else global[key] = value;
}
}
});

test("live reconciliation refuses a concurrent assignment without writing", async () => {
const value = cluster();
const item = owned(20, value, { labels: [{ name: "aw-essential" }] });
Expand Down Expand Up @@ -331,7 +384,11 @@ test("reconciliation retires before creation and labels replacements", async ()
},
},
};
await reconcile(github, "github", "gh-aw", [cluster()], data, false, { info() {} });
const createIssue = async parameters => {
const { data: created } = await github.rest.issues.create(parameters);
return { staged: false, issue: created };
};
await reconcile(github, "github", "gh-aw", [cluster()], data, false, { info() {} }, createIssue);
assert.deepEqual(calls, ["retire", "create"]);
});

Expand Down
26 changes: 24 additions & 2 deletions .github/workflows/aw-issue-clustering.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion .github/workflows/aw-issue-clustering.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,19 @@ safe-outputs:
uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Set up safe-output helpers
uses: ./actions/setup
- name: Revalidate live provenance and publish essential assignments
uses: actions/github-script@v9.0.0
env:
GH_AW_AGENT_OUTPUT: ${{ runner.temp }}/gh-aw/safe-jobs/agent_output.json
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const { setupGlobals, createIssue } = require(`${process.env.RUNNER_TEMP}/gh-aw/actions/index.cjs`);
setupGlobals(core, github, context, exec, io, getOctokit);
const { publish } = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/aw_issue_clustering_publish.cjs`);
await publish({ github, context, core });
await publish({ github, context, core, createIssue });
---

# AW essential ten
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/daily-choice-test.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions .github/workflows/mcp-inspector.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading