Skip to content

[steps] Support local composite functions in hook steps - #4062

Open
sswrk wants to merge 5 commits into
szymonswierk/eng-22387-steps-unify-no-if-gating-policyfrom
szymonswierk/eng-22387-steps-composite-functions-in-hooks
Open

[steps] Support local composite functions in hook steps#4062
sswrk wants to merge 5 commits into
szymonswierk/eng-22387-steps-unify-no-if-gating-policyfrom
szymonswierk/eng-22387-steps-composite-functions-in-hooks

Conversation

@sswrk

@sswrk sswrk commented Jul 23, 2026

Copy link
Copy Markdown
Member

Why

Hook steps currently reject uses: ./... local composite functions. Users should be able to reuse the same composites in hooks as in the main workflow.

How

A composite function used in a hook is expanded into its inner steps at parse time, the same way it is in the main workflow, and the whole expansion becomes a single hook entry. The step's if: gates the expansion as a whole.

Hook construction now takes a catalog of composite functions. Without it, referencing a composite fails with a missing-function error instead of the previous "not supported in hooks" error.

Wiring reading the composite functions files will be added in follow-up PRs in the stack.

Test Plan

Added unit tests.

@linear-code

linear-code Bot commented Jul 23, 2026

Copy link
Copy Markdown

ENG-22387

sswrk commented Jul 23, 2026

Copy link
Copy Markdown
Member Author

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 62.23%. Comparing base (e693624) to head (72786a9).

Additional details and impacted files
@@                                    Coverage Diff                                     @@
##           szymonswierk/eng-22387-steps-unify-no-if-gating-policy    #4062      +/-   ##
==========================================================================================
+ Coverage                                                   62.22%   62.23%   +0.01%     
==========================================================================================
  Files                                                         995      995              
  Lines                                                       44754    44764      +10     
  Branches                                                     9412     9415       +3     
==========================================================================================
+ Hits                                                        27845    27855      +10     
  Misses                                                      15462    15462              
  Partials                                                     1447     1447              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from ccd27cf to 88b25ed Compare July 23, 2026 16:17
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch 2 times, most recently from e118ae0 to 5a88848 Compare July 23, 2026 17:06
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from 88b25ed to afe2ad9 Compare July 23, 2026 17:06
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from afe2ad9 to de031b5 Compare July 24, 2026 11:43
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch 2 times, most recently from 642f105 to 40b60f7 Compare July 24, 2026 11:49
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch 2 times, most recently from 73ece96 to be7ec02 Compare July 24, 2026 12:31
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch 2 times, most recently from fd5922a to 44fec11 Compare July 24, 2026 13:02
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch 2 times, most recently from 0236cea to f14d4f6 Compare July 24, 2026 14:12
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from 44fec11 to 56907d3 Compare July 24, 2026 14:12
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from f14d4f6 to fb5d85f Compare July 24, 2026 14:40
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from 56907d3 to a107305 Compare July 24, 2026 14:41
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from fb5d85f to 0a629b7 Compare July 24, 2026 14:59
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from a107305 to 95e91de Compare July 24, 2026 14:59
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from 0a629b7 to 32f79bd Compare July 24, 2026 15:16

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review for a one-time review, or @claude review always to subscribe this PR to a review on every future push.

Tip: disable this comment in your organization's Code Review settings.

@hSATAC hSATAC left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A few non-blocking nits. Most of them are about composite behaviour that predates this PR rather than anything it introduces, so they are all fine to split out into a separate PR if you would rather keep this one focused.

buildFunctionGroupById: BuildFunctionGroupById;
};

export type FunctionMapsWithExpander = FunctionMaps & {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit, not blocking.

CompositeFunctionExpander already holds its own functionMaps, so this type carries the maps twice and nothing stops a caller from assembling a bag whose expander was built from a different pair of maps. Passing just the expander, or exposing the maps off it, would make that impossible to get wrong. Happy to leave it if you prefer keeping the destructuring at the call sites as it is.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Cleaned this up

Comment thread packages/steps/src/hooks.ts Outdated
* Composite calls are also one entry, but their call-site `if:` lives on the
* expansion scope (not `ifCondition`), matching main-workflow composites. A
* passing call `if:` therefore does not grant the group-entry `!entryFailed`
* shield; no-`if:` children follow the plain hook default.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit, not blocking, and I think this predates the PR rather than being caused by it, so it is fine as a separate PR.

The paragraph is accurate, but the consequence is sharper than it reads. When an earlier entry has already failed and the call if: passes, the no-if: children skip as described, while the synthetic outputs node runs anyway, since it is constructed with ifCondition: '${{ always() }}'. So the call reports SUCCESS and publishes its declared outputs resolved against skipped children, which come out as empty strings. A later step reading ${{ steps.<call-id>.outputs.x }} silently gets ''.

I checked and the same thing happens in the main workflow, so this PR does not introduce it. Raising it here because hooks are where people write if: always(), so it will start showing up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid point. Deferring it to a follow-up PR as this seems like a bigger change.

ENG-25537

Comment thread packages/steps/src/BuildWorkflow.ts Outdated
// The one wording for an `if:` that could not be evaluated — anchor steps,
// hook steps, and hook group entries all log through here.
// Shared wording for unevaluable `if:` gates. `ifCondition` is omitted when the
// failure is a composite call-site `if:` evaluated via the step's scope.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit, not blocking, and fine to split into its own PR.

The omission is conditional on the child having an if:, not on where the failure came from. When a composite call-site if: throws and the child does have its own if:, this quotes the child's condition as the culprit even though that condition is valid and did not fail. And because isActive memoizes with ??=, a throw is never cached, so every remaining child re-evaluates the same broken expression and logs the same pair of lines. A 10-step composite produces 10 copies naming 10 different steps, none of which is the one the user actually wrote the condition on.

The same misattribution already exists in the main-workflow loop further up, so it is not new. Flagging it here because this PR is the one touching the message.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yeah, not specific to this PR but a valid point. Changed the message a bit and deferring the fix to a follow-up PR.

ENG-25535

);
entries.push({
steps: compositeFunctionExpander
.expandCompositeFunctionStep(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit, not blocking, pre-existing rather than yours, and fine as a separate PR.

expandCompositeFunctionStep forwards name, with, if and env from the call step and rejects working_directory with a good message, but it never reads step.shell, so shell: on a composite call is silently dropped. Same class of thing as working_directory, just unguarded. Worth either rejecting it the same way or forwarding it to the inner steps.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I think we're fine because https://github.com/expo/eas-cli/blob/main/packages/eas-build-job/src/step.ts#L97

We can follow-up with further improving the handling of this like in working_directory.

parseLocalCompositeFunctionPath(step.uses),
BuildStep.getNewId(step.id)
)
.getFlattenedSteps(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit, not blocking, and arguably my area rather than yours. Also fine as a separate PR.

getFlattenedSteps() appends the synthetic outputs node whenever the composite declares outputs:, and that node runs under always(). Since anyStepExecuted is the only gate on reportWorkflowHookMetric, a hook side where every authored child was skipped still reports one eas.workflow.hook event with result: 'success' when the composite declares outputs, and reports nothing when it does not. Same hook, same outcome, different signal depending on whether an outputs: block happens to be there. I would rather the metric did not depend on that, but it is not urgent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Applied a fix: 72786a9

@hSATAC hSATAC left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

One thing I noticed while reviewing this: I don’t think this PR is doing anything wrong, but it does make an existing behavior difference more likely to surface for users.

Today, when a single hook entry expands into multiple steps, the later steps are gated differently depending on which side the hook runs on:

  • In a before_* hook, if an earlier step fails, later steps without an if: are skipped.
  • In an after_* hook, later steps still run.

This PR preserves that existing behavior. The difference is that, until now, multi-step hook entries could only come from function groups that we own. We don’t have many function groups, and users are also unlikely to use them inside hooks, so this difference probably hasn’t been very visible in practice.

With composite functions, users can now define and reuse the same multi-step unit themselves:

# .eas/functions/publish/function.yml
runs:
  steps:
    - run: ./login.sh
    - run: ./push.sh

When used in steps:, ./push.sh is skipped if ./login.sh fails. When the same composite is used in after_build, ./push.sh still runs. I verified this behavior on the branch.

I think this could be surprising because the author of a composite may not know where it will eventually be used, so the behavior isn’t something they can fully account for inside the composite itself.

I don’t think this needs to block the PR, but I wanted to flag it so we can decide whether we want to document the difference, align the default behavior for after_* hooks, or keep it as-is.

One related detail if we document it: success() and failure() inside a composite reflect the overall job status, not the result of the previous step. That means if: ${{ success() }} is not a reliable fail-fast guard inside an after_* hook, since the hook may already be running after the job has failed.

To express “only run if the previous step succeeded,” users would need to pass that state explicitly, for example:

- id: login
  run: |
    ./login.sh
    set-output ok true
  outputs: [ok]

- run: ./push.sh
  if: ${{ steps.login.outputs.ok == 'true' }}

The same behavior applies to function groups, so whatever we decide here could cover both.

@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from 336986e to bfe042d Compare July 28, 2026 12:58
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from 98ef96f to ff9cb50 Compare July 28, 2026 12:58
@sswrk

sswrk commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

@hSATAC Thank you for noting that, and yes, this was done like that for the sake of consistency. But taking a step back, as a user I'd expect composite functions/function groups to fast-fail within them even in the after_* hook (and then let the next steps in the hook run).

Corrected the behavior for both composite functions and function groups.

@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from ff9cb50 to 4a56ee2 Compare July 28, 2026 13:11
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from bfe042d to c692851 Compare July 28, 2026 15:30
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from 4a56ee2 to 70ce854 Compare July 28, 2026 15:30
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from c692851 to f9f51cc Compare July 29, 2026 09:01
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from 70ce854 to 3702906 Compare July 29, 2026 09:01
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-unify-no-if-gating-policy branch from f9f51cc to e693624 Compare July 29, 2026 09:26
@sswrk
sswrk force-pushed the szymonswierk/eng-22387-steps-composite-functions-in-hooks branch from 3702906 to 7136a56 Compare July 29, 2026 09:27
@github-actions

Copy link
Copy Markdown

⏩ The changelog entry check has been skipped since the "no changelog" label is present.

@sswrk
sswrk requested a review from hSATAC July 29, 2026 13:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no changelog PR that doesn't require a changelog entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants