Skip to content

fix: use block-scope var destructuring for script variable injection#711

Closed
salmanmkc wants to merge 1 commit intomainfrom
salmanmkc/block-scope-injection
Closed

fix: use block-scope var destructuring for script variable injection#711
salmanmkc wants to merge 1 commit intomainfrom
salmanmkc/block-scope-injection

Conversation

@salmanmkc
Copy link
Copy Markdown
Contributor

Summary

Replace function-parameter injection in callAsyncFunction with var destructuring + block scope. This is a non-breaking fix that allows user scripts to shadow injected names (github, core, context, etc.) with const, let, or var without getting SyntaxError.

Problem

callAsyncFunction currently uses new AsyncFunction(...Object.keys(args), source), which makes every injected variable a function parameter. JavaScript forbids const/let redeclaration of function parameters, so user scripts like:

const { getOctokit } = require("@actions/github")

throw SyntaxError: Identifier 'getOctokit' has already been declared. This affects ~10 public workflows (MetaMask, github/migration-actions, etc.) and blocks exposing new bindings without breaking existing scripts.

Solution

Pass a single __scope__ parameter and destructure with var, then wrap user code in a block:

var { context, core, github, ... } = __scope__;
{
  // user script — const/let/var all work here
}

Why var?

  • const/let in user code create block-scoped bindings that shadow the outer var — ✅
  • var in user code redeclares the existing var — ✅ (would fail with const)
  • Reassignment (github = x) still works — ✅ (would fail with const)

This preserves full backward compatibility with existing v7 scripts.

Defensive guards

  • Identifier validation: All argument keys are checked against /^[a-zA-Z_$][a-zA-Z0-9_$]*$/ before interpolation into the code string. callAsyncFunction is an exported API, so this prevents code injection if called with untrusted keys.
  • __scope__ collision guard: Rejects __scope__ as an argument name to prevent collision with the internal parameter.

Tests added (11 new)

Test What it verifies
const redeclaration Block-scope shadow works
let redeclaration Block-scope shadow works
var redeclaration Function-scope redeclaration works
Reassignment foo = "new" works
Access without redeclaration Injected values still accessible
Return from block scope return propagates correctly
Top-level await await works inside block
Syntax errors Still throw SyntaxError
Invalid argument names Rejects non-identifiers
__scope__ collision Rejects reserved name
Original basic test Preserved from v7

Breaking changes

None. This is fully backward compatible:

  • All existing scripts work unchanged
  • Scripts that previously worked with var redeclaration still work
  • Scripts that previously worked with reassignment still work
  • The only new capability is that const/let redeclaration now works too

Copilot AI review requested due to automatic review settings April 9, 2026 15:04
@salmanmkc salmanmkc requested a review from a team as a code owner April 9, 2026 15:04
@salmanmkc salmanmkc temporarily deployed to debug-integration-test April 9, 2026 15:04 — with GitHub Actions Inactive
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

Hello from actions/github-script! (8ea1409)

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the callAsyncFunction script runner to inject bindings via a single __scope__ object plus var destructuring and a block wrapper, so user scripts can redeclare injected names with const/let without hitting parameter redeclaration SyntaxErrors.

Changes:

  • Replace function-parameter injection with __scope__ + var {…} = __scope__ destructuring and a block-wrapped user script.
  • Add runtime guards for invalid argument names and reserved __scope__ collisions.
  • Add Jest tests covering redeclaration/shadowing behavior, reassignment, awaits, syntax errors, and guard failures.
Show a summary per file
File Description
src/async-function.ts Switches injection strategy to block-scoped shadowable bindings and adds defensive argument-name validation.
test/async-function.test.ts Adds test coverage for shadowing/redeclaration semantics and the new validation guards.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 2/2 changed files
  • Comments generated: 3

Comment on lines +43 to +46
const fn = new AsyncFunction(
'__scope__',
`var {${keys.join(', ')}} = __scope__;\n{\n${source}\n}`
)
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping the user-provided source in an extra block means directive prologues in user scripts (e.g. starting with 'use strict') are no longer at the function body top level, so strict mode will be silently ignored. This is a behavioral change compared to the previous new AsyncFunction(..., source) implementation and contradicts the PR’s “non-breaking” claim; consider preserving directive prologues (e.g. detect/hoist them) or explicitly documenting this change (and adding a regression test either way).

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +37
// Guard: all keys must be valid JS identifiers (defensive for exported API)
const identRe = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/
for (const key of keys) {
if (!identRe.test(key)) {
throw new Error(`Invalid argument name: ${key}`)
}
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The identifier guard only checks a simple ASCII identifier regex; it will accept reserved words that are invalid binding identifiers in an async function (notably await), which will still cause new AsyncFunction(...) to throw SyntaxError after passing validation. If this is meant to be a defensive/clear error for exported API callers, expand validation to reject reserved words (at least await) or validate by compiling a minimal binding declaration before interpolating.

Copilot uses AI. Check for mistakes.

expect(result).toEqual('async-value')
})

Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There’s no regression test for directive prologues (e.g. a script starting with 'use strict'). With the new block wrapper this behavior changes (strict mode would be ignored), so it would be good to add a test that locks in the intended semantics once the wrapper is adjusted.

Suggested change
test('preserves directive prologues such as use strict', async () => {
await expect(
callAsyncFunction(
{} as any,
`
'use strict'
accidentalGlobal = 1
`
)
).rejects.toBeInstanceOf(ReferenceError)
})

Copilot uses AI. Check for mistakes.
@salmanmkc salmanmkc force-pushed the salmanmkc/block-scope-injection branch from a94a06d to 10b2431 Compare April 9, 2026 15:15
@salmanmkc salmanmkc temporarily deployed to debug-integration-test April 9, 2026 15:16 — with GitHub Actions Inactive
@salmanmkc salmanmkc force-pushed the salmanmkc/block-scope-injection branch from 10b2431 to c707dcb Compare April 9, 2026 15:26
@salmanmkc salmanmkc temporarily deployed to debug-integration-test April 9, 2026 15:26 — with GitHub Actions Inactive
@salmanmkc salmanmkc force-pushed the salmanmkc/block-scope-injection branch from c707dcb to 7cb25a2 Compare April 9, 2026 15:32
@salmanmkc salmanmkc temporarily deployed to debug-integration-test April 9, 2026 15:32 — with GitHub Actions Inactive
@salmanmkc salmanmkc force-pushed the salmanmkc/block-scope-injection branch from 7cb25a2 to 88e4ad0 Compare April 9, 2026 15:55
@salmanmkc salmanmkc temporarily deployed to debug-integration-test April 9, 2026 15:55 — with GitHub Actions Inactive
Add pre-configured getOctokit factory to the script context, enabling
multi-token workflows directly inside github-script.

- getOctokit(token, opts, ...plugins) inherits action config (retry, userAgent, etc.)
- Deep-merges request and retry options, deduplicates plugins
- stripUndefined prevents baseUrl clobber on GHES
- Integration test job with dynamic repo name (fork-friendly)
- 32 tests passing (15 factory + 4 integration + 6 getOctokit + 7 existing)
@salmanmkc salmanmkc force-pushed the salmanmkc/block-scope-injection branch from 88e4ad0 to dae91cf Compare April 9, 2026 15:59
@salmanmkc salmanmkc temporarily deployed to debug-integration-test April 9, 2026 15:59 — with GitHub Actions Inactive
@salmanmkc
Copy link
Copy Markdown
Contributor Author

Closing in favor of PR #700 which includes both the getOctokit factory and the v9 upgrade. No need for a separate v6 backport.

@salmanmkc salmanmkc closed this Apr 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants