Skip to content

update_project: a string-typed fields value creates bogus per-character project fields (0, 1, 2, …) #44845

Description

@ssulei7

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 022, 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:

  1. If output.fields is a string, attempt JSON.parse; on success use the object, on failure warn and skip.
  2. 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.
  3. 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.

Metadata

Metadata

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions