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
stacksentry names withenvFileand 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, onscan,apply,auto, andcheckwithbackend: true. It names one file ofNAME=valuelines, 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,trueandfalse, and a value of 1 to 3 characters. A mask that short turns everydevor1in 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 maskedprodor8080costs 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 isNAME=value, with an optionalexportin 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 withINPUT_(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,initandcheckwithoutbackend: truethe 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. Inautonothing 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
runcommand resolves the references into the environment of the process it starts, and something has to hand them on to the job;export-env.shdoes that, in memory. A resolved file, written byop injector the like, and a file of plain values need no script. The docs say so, andinitis 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 andinitcannot 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-fileinput 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 thatresolveandsettlenever open the file.docs/credentials.md,docs/security.md,SECURITY.mdand 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
1ordevstars 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
TOKENorSECRETin 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 runand a shell’ssourcelet 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 inrender/env-file.ts. Reading the file and masking are glue,github/env-file.ts, which the check job may reach only forbackend: true, and its import walk says so.
Consequences
action.ymlgainsenv-file, anddocs/reference.mdits row. The build plan’s section 3 lists it.CONTEXT.mdgains 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 workflowinitwrites say in their comment that the env-file input is the other way to load credentials. docs/later.mdgains several files per step, expansion, inline comments, and resolving references.