Skip to content
Go to console
Go to console

The env-file input loads the file a step names, masks every value, and promise 2 is amended

Decision record 0100

Amended by 0103: one file per step, and one more per stack, which a stacks entry names with envFile and the same reader loads on top of the step’s file for that stack alone.

Every repo that keeps its credentials in a secret manager’s env file copied the same two things into its workflow: a loading step and export-env.sh, a script that masks each value and writes it to $GITHUB_ENV (onboarding log, hurdle 3). It is about twelve lines plus a subtle script, the same in every repo, and getting the masking wrong leaks values into the job log (hurdle 11). Issue 226, the owner on 2026-09-23: the action is open source, pinned by the user and runs on the user’s own runner, never on Sluiceway’s; its process already holds the whole job environment, which record 0014 says in its own text; so reading a file the user points at adds no exposure that is not already there, only code the user can read. Record 0078 is the precedent: it amended promise 1 to accept the notification channels as inputs, because they are the user’s own secrets from the user’s own repo. Build plan slice 5.35.

This amends 0014 (promise 2) and 0013 (the tool’s environment is the job’s, unchanged), and holds 0065 as it is.

Decision

  • One input, env-file, on scan, apply, auto, and check with backend: true. It names one file of NAME=value lines, relative to the checkout or absolute. The glue of each of those modes reads it once, before anything else, and hands the tool an environment that is the job’s with the file’s values on top. Empty by default, and then nothing changes: the tool gets the job environment as it is (0013).
  • Every value is masked before anything else happens, with the runner’s setSecret, and each line of a value that spans lines on its own, because the runner matches the log line by line. Three kinds of value get no mask: an empty one, true and false, and a value of 1 to 3 characters. A mask that short turns every dev or 1 in the log into stars and breaks links (hurdle 11). Everything from 4 characters on is masked whatever it looks like: the first cut left values under 8 characters unmasked, and the owner, on 2026-09-24 reviewing this slice, called that a hole, since a 6 or 7 character password would print in the log; a masked prod or 8080 costs less than that. The job log gets one group that names what was loaded, what was masked, what was not and why, and what the file replaced, and never a value.
  • The file wins over the job environment. The person named the file on purpose and reads it as the truth. If a variable already in the job’s environment won, a stale value from a workflow env: block or an earlier step would silently shadow the file, and nobody would see it. The log names each name the file replaced, so the other order is visible too. What Sluiceway reads for itself, the repo, the run, its inputs, comes from the runner’s environment and never from the file, so a file cannot change which repo or run Sluiceway acts on.
  • The format is strict, and what is refused is said by line number, never by quoting the line, because a broken line may hold a secret. A comment line starts with #, blank lines are skipped, and every other line is NAME=value, with an optional export in front and white space around the =, as the script accepted. An unquoted value runs to the end of its line with the white space around it removed, and a # in it is part of it: a comment after a value would be a guess about a # in a password. A value in double quotes may span lines and knows the escapes \n, \" and \\, for keys and certificates. A value in single quotes is taken as it is, across lines too. Nothing may follow a closing quote. Refused: any other line, a name set twice, a quote never closed, text after a closing quote, another escape in double quotes, a name that starts with INPUT_ (the tool never gets those, 0013, so the line would look like it sets an input and do nothing), and a value that holds a secret reference (op://), because Sluiceway resolves none and the tool would get the reference as its credential. A Windows line ending and a byte order mark are read. An empty value is loaded as empty. No ${NAME} expansion: it would read the job environment by name.
  • A file that is not there fails the step before the tool runs, in the modes that run the tool, with the path and whether it was looked for in the checkout. In resolve, settle, init and check without backend: true the input is a warning, “Env file input not used”, and the file is never opened: those modes hold no credentials (0014, promise 4), and an error would stop a tick over a harmless mistake. In auto nothing is said, because the step may run the tool on another event.
  • One file per step. A value with more than one line is refused with the words that a step before Sluiceway can join files. Two files would raise which wins on a name both set, the question the format refuses inside one file, and one bulk load per job is the pattern of 0013.
  • The script stays for one pattern only: a file of secret references. A secret manager’s run command resolves the references into the environment of the process it starts, and something has to hand them on to the job; export-env.sh does that, in memory. A resolved file, written by op inject or the like, and a file of plain values need no script. The docs say so, and init is unchanged: it writes the script only for a file of references it found (0065), and leaves a file of plain values alone, because such a file is most often a local development file and init cannot tell.
  • Promise 2 of 0014 is amended, and every page that made it says so. It read “Never read by name. No Sluiceway code reads a credential variable. The environment goes to the tool as one opaque block.” It now reads: no Sluiceway code reads a credential variable of the job environment, and the one file it reads is the one the env-file input names, every line of it the same way, for the tool, masked first. Promise 1 gains that the input carries a path and never a value. Promise 3 covers the env file’s values. Promise 4 gains that resolve and settle never open the file. docs/credentials.md, docs/security.md, SECURITY.md and the README are reworded in the same change, and the README’s first promise no longer says “never holds”: the process holds what the job environment holds, and now what the file holds, and says so.

Considered

  • Masking every value, whatever its length. Rejected for 1 to 3 characters only: a mask of 1 or dev stars every such text in the log, and the first real user met exactly that with a username (hurdle 11). A line at 8 characters was the first cut and was rejected by the owner as a hole, above.
  • Masking by name, such as anything with TOKEN or SECRET in it. Rejected as a guess: the file is the user’s, and a value is a secret because it is in the file, not because of its name.
  • The job environment winning over the file. Rejected above. op run and a shell’s source let the file win too.
  • Several files, in a list. Rejected above, and listed in docs/later.md.
  • Resolving secret references in the file. Rejected: it needs the secret manager’s own credential, which promise 1 keeps out of Sluiceway. Listed in docs/later.md.
  • An inline comment after an unquoted value, as dotenv reads one. Rejected: a # in a password would be cut without a word. A comment goes on its own line.
  • Reading the file in the pure core and handing the mode the values. The parser and the mask rule are pure, in core/env-file.ts, and the words in render/env-file.ts. Reading the file and masking are glue, github/env-file.ts, which the check job may reach only for backend: true, and its import walk says so.

Consequences

  • action.yml gains env-file, and docs/reference.md its row. The build plan’s section 3 lists it.
  • CONTEXT.md gains the env file.
  • The e2e loop’s first scan names an env file with a canary value, and a check holds that the value was masked before the log said the file was loaded, that a short value was not, and that the value reached nothing Sluiceway writes.
  • The README, docs/workflow.md, the split workflow, the read-only trial and the workflow init writes say in their comment that the env-file input is the other way to load credentials.
  • docs/later.md gains several files per step, expansion, inline comments, and resolving references.