Summary
When a update_project safe output is emitted with fields as a JSON-encoded string instead of a JSON object, the handler (actions/setup/js/update_project.cjs) iterates over the string's character indices and creates one SINGLE_SELECT project field per index — named 0, 1, 2, …. This silently pollutes the target Project v2 with dozens of junk fields.
Root cause
main() gates on truthiness/length only, without a type check:
// actions/setup/js/update_project.cjs (~L1105 and ~L1194)
if (output.fields && Object.keys(output.fields).length > 0) {
await applyFieldUpdates(github, projectId, itemId, output.fields);
}
applyFieldUpdates then does:
// ~L450
for (const [fieldName, fieldValue] of Object.entries(fields)) {
...
if (!field) { /* createProjectV2Field(... SINGLE_SELECT ...) */ }
}
If output.fields is the string '{"Lifecycle":"Sandbox"}':
Object.keys(string) → ["0","1",…,"22"] (length > 0, so the guard passes)
Object.entries(string) → [["0","{"],["1","\""], …]
- each numeric key is an unknown field → a
SINGLE_SELECT field named 0, 1, … is created
So the number of junk fields equals the length of the escaped JSON string. Different-length payloads across runs produce overlapping ranges (e.g. {"Estimated Hours":120,"Sizing":"L"} → indices up to ~36).
Reproduction
Emit an update_project safe output where fields is a stringified object, e.g.:
{"type":"update_project","project":"https://github.com/orgs/ORG/projects/1",
"content_type":"issue","content_number":54,
"fields":"{\"Lifecycle\":\"Sandbox\"}"}
Result: project gains fields 0–22, each SINGLE_SELECT.
(Observed in the wild: an LLM agent intermittently double-encodes fields. The correct object form "fields":{"Lifecycle":"Sandbox"} works fine; the stringified form corrupts the board.)
Impact
- Project v2 boards accumulate dozens of junk single-select fields; requires manual GraphQL
deleteProjectV2Field cleanup.
- Fails silently — the run succeeds, so it's easy to miss until the board is visibly polluted.
- Triggered by a plausible/common agent serialization mistake (stringified JSON), not by malformed workflow config.
Expected behavior
The handler should not treat a string fields as an iterable of characters. Options:
- If
output.fields is a string, attempt JSON.parse; on success use the object, on failure warn and skip.
- If
fields is not a plain object (string / array / null) after coercion, emit a validation error/warning and skip field updates instead of creating fields.
- Optionally, guard field-creation against numeric-only / obviously-bogus field names.
Minimal guard sketch:
let fields = output.fields;
if (typeof fields === "string") {
try { fields = JSON.parse(fields); }
catch { core.warning("update_project: `fields` was a string and not valid JSON; skipping field updates"); fields = null; }
}
if (fields && typeof fields === "object" && !Array.isArray(fields) && Object.keys(fields).length > 0) {
await applyFieldUpdates(github, projectId, itemId, fields);
} else if (fields != null) {
core.warning("update_project: `fields` must be a JSON object; skipping field updates");
}
Environment
- Reproduced with gh-aw v0.81.6; the same unguarded code path is present on main (
actions/setup/js/update_project.cjs, ~L1105/L1194 and applyFieldUpdates ~L450).
- Engine: Copilot; safe output:
update_project targeting an org Project v2.
Summary
When a
update_projectsafe output is emitted withfieldsas a JSON-encoded string instead of a JSON object, the handler (actions/setup/js/update_project.cjs) iterates over the string's character indices and creates oneSINGLE_SELECTproject field per index — named0,1,2, …. This silently pollutes the target Project v2 with dozens of junk fields.Root cause
main()gates on truthiness/length only, without a type check:applyFieldUpdatesthen does:If
output.fieldsis the string'{"Lifecycle":"Sandbox"}':Object.keys(string)→["0","1",…,"22"](length > 0, so the guard passes)Object.entries(string)→[["0","{"],["1","\""], …]SINGLE_SELECTfield named0,1, … is createdSo the number of junk fields equals the length of the escaped JSON string. Different-length payloads across runs produce overlapping ranges (e.g.
{"Estimated Hours":120,"Sizing":"L"}→ indices up to ~36).Reproduction
Emit an
update_projectsafe output wherefieldsis a stringified object, e.g.:{"type":"update_project","project":"https://github.com/orgs/ORG/projects/1", "content_type":"issue","content_number":54, "fields":"{\"Lifecycle\":\"Sandbox\"}"}Result: project gains fields
0–22, eachSINGLE_SELECT.(Observed in the wild: an LLM agent intermittently double-encodes
fields. The correct object form"fields":{"Lifecycle":"Sandbox"}works fine; the stringified form corrupts the board.)Impact
deleteProjectV2Fieldcleanup.Expected behavior
The handler should not treat a string
fieldsas an iterable of characters. Options:output.fieldsis a string, attemptJSON.parse; on success use the object, on failure warn and skip.fieldsis not a plain object (string / array / null) after coercion, emit a validation error/warning and skip field updates instead of creating fields.Minimal guard sketch:
Environment
actions/setup/js/update_project.cjs, ~L1105/L1194 andapplyFieldUpdates~L450).update_projecttargeting an org Project v2.