Running the same thing locally and in CI has been a goal from early on. If a project’s build is a flow executable, then CI should run that, not a hand-copied approximation of it that drifts the first time someone changes a flag.
flowexec/action is how. It publishes to the Marketplace
as flow-execute, and the smallest useful thing you can write with it is:
- uses: flowexec/action@v1
with:
executable: 'build app'
That is the whole point of it. The executable named there is the same one you run with
flow build app at your desk. Every repository in the flowexec organization uses this on itself.
What It Actually Is
A composite action, not a container or a JavaScript action. It is a handful of bash steps in a trench coat, which is the right shape for something whose job is to install a binary and run it:
- Resolve where flow should be installed, then restore it from the runner cache.
- Install the CLI if the cache missed.
- Register workspaces, cloning any that are git remotes.
- Create a vault and load secrets into it, but only if secrets were passed.
- Run the executable.
- Upload logs as an artifact, but only on failure, and only if asked.
Keeping it composite means each step shows up separately in the workflow log, so a failure points at the thing that failed rather than at one opaque action.
Workspaces, Including Ones That Are Not There Yet
The interesting input is workspaces. A workspace can be a local path, but it can also be a git
URL, which the action clones and registers before running anything:
- uses: flowexec/action@v1
with:
executable: 'deploy staging'
workspaces: |
backend: ./backend
frontend: https://github.com/user/frontend-repo.git
shared:
repo: https://github.com/myorg/shared-flows.git
ref: v1.0.0
clone-token: ${{ secrets.GITHUB_TOKEN }}
This is the CI expression of flow’s cross-project composition. A workflow can pull in a shared workspace of common executables, pin it to a tag, and reference its executables the same way it would locally. Clone depth defaults to 1, because CI almost never needs the history.
Secrets and the Ephemeral Vault
Secrets were the part that needed real thought. flow reads secrets from a vault, and a CI runner has no vault, so the action makes one and throws it away.
When secrets are passed, it creates a vault named github-actions keyed to an environment
variable, loads each secret in, and switches to it. The generated key is immediately masked in
the log with ::add-mask:: and exposed as an output.
That output exists for one reason, and it is the nicest bit of the design: a vault can outlive a job. Emit the key from one job, pass it to the next, and the second job decrypts the same vault rather than re-loading every secret from GitHub:
jobs:
setup:
outputs:
vault-key: ${{ steps.init.outputs.vault-key }}
steps:
- uses: flowexec/action@v1
id: init
with:
executable: 'validate'
secrets: |
SHARED_SECRET=${{ secrets.SHARED_SECRET }}
deploy:
needs: setup
steps:
- uses: flowexec/action@v1
with:
executable: 'deploy production'
vault-key: ${{ needs.setup.outputs.vault-key }}
The vault step is skipped entirely when there are no secrets and no key, so a plain build job does not pay for machinery it is not using.
Failing Usefully
A CI action that only tells you “exit code 1” is not much better than running the command yourself. This one parses flow’s structured JSON error output and surfaces the code:
| Output | What it carries |
|---|---|
exit-code | The executable’s exit code |
error-code | A machine-readable code such as EXECUTION_FAILED, TIMEOUT, NOT_FOUND |
output | Captured stdout, when upload is on |
vault-key | The generated key, when secrets were configured without one |
Which means a workflow can branch on why something failed rather than just that it did:
- name: Handle failure
if: steps.migrate.outputs.exit-code != '0'
run: |
if [ "${{ steps.migrate.outputs.error-code }}" = "TIMEOUT" ]; then
echo "Consider increasing the timeout"
fi
Captured output is truncated at 65,000 bytes, because that is GitHub’s limit on a step output, and the full log is available as an artifact instead.
The Unglamorous Parts
Most of the commit history is Windows and shell edge cases, which is what this kind of tool is actually made of:
- Windows runners need
$HOME/binpushed ontoGITHUB_PATH, and workspace paths in native form rather than the POSIX form the rest of the script assumes. TERM=dumbon Windows, because flow’s TUI would otherwise try to render into something that is not a terminal and hang the job.- The vault key is extracted from structured JSON output, with a fallback to scraping the plain text message for older CLI versions.
- The binary is cached between runs, keyed on the resolved version, so a workflow that runs the action several times installs flow once.
None of that is interesting to write about, and all of it is the difference between an action that works on your machine and one that works on someone else’s.