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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions