Module 2: Anatomy Of A Github Actions Workflow

7. Hands-on: passing secrets to `act`

Description

A real workflow almost never runs without at least one credential —an API key, a token, in this guide LocalStack's dummy credentials. GitHub Actions solves this with Secrets, managed outside the YAML (Module 4 covers them in depth). This lesson teaches you act's side of it: how to pass those same secrets to a local run, without ever writing them inside the workflow file or letting them into a commit. You're going to create .secrets —gitignored from the exact moment it exists— and you're going to confirm, with literal output, that a secret reaches a step and that its absence also shows.

Connection to the module

This lesson keeps working inside andes-cargo-infra/, building directly on what you left in lesson 6. This module's project (lesson 8) uses exactly this lesson's mechanism to pass hello-andes-cargo.yml the dummy credentials it needs to talk to LocalStack. Module 4 returns to secrets with much more detail —real GitHub Secrets, environment scoping, and why a long-lived credential in a repository is the competition's most-cited security antipattern— but the act mechanism you learn here doesn't change.


Analogy: the safe with the combination kept separate from the blueprint

If pr-event.json (lesson 6) was a rehearsal's script, a secret is the combination to a safe that appears in that play's props. You'd never write the real combination in the printed script —which anyone in the cast, the crew, future productions is going to read— you give it to the actor separately, in the moment, outside the text that stays archived forever. .secrets is exactly that: a file that lives outside Git's history —never committed— that hands act the combination at the moment of the run, without that combination ever getting recorded anywhere permanent.


Step 1 — Create .secrets, and gitignore it starting now

Keep standing in andes-cargo-infra/. .secrets, at the project's root:

AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test

The dummy test/test credentials are the same ones LocalStack accepts without validating against any real AWS account —the same ones you already used in terraform-and-iac-guide and aws-core-services-guide. Before doing anything else, protect this file:

echo "" >> .gitignore
echo "# Local secrets for \`act --secret-file\` (never commit real credentials)" >> .gitignore
echo ".secrets" >> .gitignore

Confirm Git already ignores it:

git status --short

What to expect (literal).secrets shouldn't show up anywhere in the output, not even as an untracked file; only .gitignore (modified) should appear:

 M .gitignore

If .secrets showed up in this list, something's wrong with your .gitignore — check it before continuing. This is, literally, the exact moment this guide's design marks as "gitignored from this lesson onward": there was never a commit with .secrets inside, not even an old one that got fixed later.


Step 2 — A workflow that confirms the secrets, without printing their value

.github/workflows/secrets-test.yml:

name: secrets-test

on: workflow_dispatch

jobs:
  check-secrets:
    runs-on: ubuntu-latest
    env:
      AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    steps:
      - name: Confirm the secrets arrived, without printing their value
        run: |
          if [ -n "$AWS_ACCESS_KEY_ID" ]; then
            echo "AWS_ACCESS_KEY_ID is set (length: ${#AWS_ACCESS_KEY_ID})"
          else
            echo "AWS_ACCESS_KEY_ID is EMPTY"
          fi
          if [ -n "$AWS_SECRET_ACCESS_KEY" ]; then
            echo "AWS_SECRET_ACCESS_KEY is set (length: ${#AWS_SECRET_ACCESS_KEY})"
          else
            echo "AWS_SECRET_ACCESS_KEY is EMPTY"
          fi

secrets.AWS_ACCESS_KEY_ID is the context syntax that reads a Secret with that name —from the same family as github.event... in lesson 6, but for the secrets context, specifically reserved for this kind of value. Notice the step never prints the secret's real value, only its length — a deliberate practice: even with dummy credentials like these, it's worth getting used to never dumping a complete secret to a log, not even in a lab.


Step 3 — act --secret-file .secrets

act workflow_dispatch -j check-secrets --secret-file .secrets

What to expect (literal output, executed to write this lesson):

[secrets-test/check-secrets] ⭐ Run Set up job
[secrets-test/check-secrets] 🚀  Start image=catthehacker/ubuntu:act-latest
[secrets-test/check-secrets]   ✅  Success - Set up job
[secrets-test/check-secrets] ⭐ Run Main Confirm the secrets arrived, without printing their value
[secrets-test/check-secrets]   | AWS_ACCESS_KEY_ID is set (length: 4)
[secrets-test/check-secrets]   | AWS_SECRET_ACCESS_KEY is set (length: 4)
[secrets-test/check-secrets]   ✅  Success - Main Confirm the secrets arrived, without printing their value [60.883584ms]
[secrets-test/check-secrets] 🏁  Job succeeded

Length 4 — the character count of the word test, with the value itself never showing up anywhere in the output. Confirms the secret arrived, without compromising it.

A real finding: .secrets is act's default name

Before continuing, an honest check is worth doing. I ran the same command without the --secret-file .secrets flag:

act workflow_dispatch -j check-secrets

What to expect (literal output, executed to write this lesson — the result is surprising):

[secrets-test/check-secrets]   | AWS_ACCESS_KEY_ID is set (length: 4)
[secrets-test/check-secrets]   | AWS_SECRET_ACCESS_KEY is set (length: 4)

The secrets arrived anyway, without the flag. Verified against act --help (version 0.2.89, this guide's): --secret-file string file with list of secrets to read from (e.g. --secret-file .secrets) (default ".secrets"). act already looks, by default, for a file named exactly .secrets in the current working directory — the same pattern as .actrc, which also doesn't need to be explicitly invoked.

This doesn't change what this lesson teaches you: keeping --secret-file .secrets written explicitly —as you did above— is good practice, not an unnecessary step. A reproducible pipeline shouldn't depend on whoever runs it knowing, from memory, an implicit act naming convention — being explicit documents the intent directly in the command, and keeps working with no changes if you ever decide to name the file differently (for example, .secrets.dev and .secrets.prod, a real pattern you're going to see mentioned in Module 4).


Step 4 — The -s KEY=value alternative, for a loose secret

Sometimes it's not worth creating a whole file for a single value —for example, while testing something specific. act accepts loose secrets directly on the command line with -s. A second workflow, deliberately simple, to practice this in isolation. .github/workflows/single-secret-test.yml:

name: single-secret-test

on: workflow_dispatch

jobs:
  check-one-secret:
    runs-on: ubuntu-latest
    env:
      DUMMY_TOKEN: ${{ secrets.DUMMY_TOKEN }}
    steps:
      - name: Confirm a secret passed with -s arrived
        run: |
          if [ -n "$DUMMY_TOKEN" ]; then
            echo "DUMMY_TOKEN is set (length: ${#DUMMY_TOKEN})"
          else
            echo "DUMMY_TOKEN is EMPTY"
          fi
act workflow_dispatch -j check-one-secret -s DUMMY_TOKEN=demo-value

What to expect (literal output, executed to write this lesson):

[single-secret-test/check-one-secret] ⭐ Run Set up job
[single-secret-test/check-one-secret] 🚀  Start image=catthehacker/ubuntu:act-latest
[single-secret-test/check-one-secret]   ✅  Success - Set up job
[single-secret-test/check-one-secret] ⭐ Run Main Confirm a secret passed with -s arrived
[single-secret-test/check-one-secret]   | DUMMY_TOKEN is set (length: 10)
[single-secret-test/check-one-secret]   ✅  Success - Main Confirm a secret passed with -s arrived [64.558333ms]
[single-secret-test/check-one-secret] 🏁  Job succeeded

Length 10 — the exact length of demo-value. -s is useful for a one-off, temporary value that doesn't need to live in a file; --secret-file is the right form when you need several secrets consistent across runs —like the AWS credentials lesson 8's project is going to need.

Commit what should stay in the history —the workflows, never .secrets:

git add -A
git commit -m "Add .secrets (gitignored) and secrets-test workflows for act --secret-file / -s"

Common mistakes

Committing .secrets before gitignoring it (this lesson's costliest mistake). What happens: someone creates .secrets with real credentials —not LocalStack's dummy test/test— and runs git add -A before updating .gitignore. Why it happens: the natural order is "first I create the file I need, then I remember to protect it" — exactly the reverse of what this lesson did. How to spot it: git status --short shows .secrets in the list of files, or worse, git log -p shows it already entered a commit. How to fix it: if you haven't run commit yet, fix .gitignore and use git rm --cached .secrets to remove it from the staging area without deleting the local file. If you already committed with a real credential inside —not a dummy one— the problem is more serious than a simple .gitignore fixes: that credential stays in Git's history forever, retrievable by anyone with access to the repository, even after deleting the file in a later commit. The only real solution in that case is revoking the exposed credential immediately —a topic Module 4, lesson 2, picks back up with the complete case.

Confusing the job's env: with the Secret's name (syntax-based). What happens: someone writes env: { AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY }} }, with different names on each side, and then .secrets defines a line AWS_ACCESS_KEY_ID=test —the environment variable's name, not the Secret's name the YAML expects. How to spot it: the secret comes out empty in the step, with no explicit error. How to fix it: the name to the left of = in .secrets has to match exactly the name that shows up inside secrets.<NAME> in the YAML —in this lesson, AWS_ACCESS_KEY_ID on both sides; the environment variable name on the left side of the job's env: can be different, if you wanted, but keeping them the same (as this lesson does) avoids exactly this kind of mistake.


Exercises

Exercise 1 — Explain why length, not value. A colleague asks you why this lesson's step prints length: 4 instead of simply echo "$AWS_ACCESS_KEY_ID", since it's a dummy LocalStack credential with no real risk anyway. Answer them in two sentences.

See solution

A complete answer sounds, roughly, like this: "It's true that test/test poses no real risk here — but the habit of never dumping a complete secret to a log is what prevents the mistake the day it does matter, with a real credential. Practicing the discipline of verifying 'it arrived, it has the expected length' instead of 'I print the value to confirm' is exactly the kind of reflex that prevents an accidental leak in a production pipeline."

Exercise 2 — Predict the behavior without the file. If you deleted .secrets entirely and ran act workflow_dispatch -j check-secrets with no secret flag, what would you expect to see in the output, based on what you learned in this lesson?

See solution

You'd expect to see AWS_ACCESS_KEY_ID is EMPTY and AWS_SECRET_ACCESS_KEY is EMPTY — without .secrets in the directory (nor a --secret-file flag pointing at another file), act has no value to assign to secrets.AWS_ACCESS_KEY_ID inside the YAML, so that expression resolves to an empty string. The job doesn't fail —GitHub Actions doesn't automatically validate that a secret exists, unless the workflow itself checks it, like this lesson's if [ -n "$AWS_ACCESS_KEY_ID" ] does.

Exercise 3 — Choose between --secret-file and -s for three scenarios. For each situation, indicate which one you'd use: (a) the dummy LocalStack credentials you're going to need on every run for the rest of this guide; (b) a test token you need just once, to confirm a step reads secrets.X correctly; (c) preparing a real pipeline that's going to use real GitHub Secrets in the future, and you want the local command to look as much as possible like how it would run in real CI.

See solution

(a) --secret-file .secrets — you need consistency across repeated runs, and several values at once; a file is the right form. (b) -s KEY=value — it's exactly the command-line use case: fast, temporary, leaving no new file in the project. (c) --secret-file .secrets — on a real GitHub pipeline, Secrets all come together, managed centrally (Module 4); a .secrets file with several lines resembles that model much more closely than passing each one separately with -s.


Summary and next step

In this lesson you created .secrets —gitignored before writing a single line of credential inside it— and confirmed, with literal output, that act --secret-file .secrets delivers those values to a workflow without exposing them in any log. You also discovered, verified against act --help, that .secrets is the default filename act looks for even without passing the flag explicitly — and why keeping it explicit remains the right practice. You closed with -s KEY=value, the alternative for a loose secret.

Before moving on you should be able to: create a .secrets correctly gitignored from the very first moment; explain the difference between --secret-file and -s, and when to use each; and say from memory why act finds .secrets even without the explicit flag.

You have both simulation techniques complete: events (lesson 6) and secrets (this lesson). Lesson 8 —this module's project— brings them together in hello-andes-cargo.yml, the first workflow that actually attempts to reach your host's LocalStack from inside the job's container.

Resources

  1. nektosact.com — User Guide — official documentation for --secret-file and -s, including .secrets's default value.
  2. GitHub Docs — Using secrets in GitHub Actions — GitHub's real Secrets model, picked back up in depth in Module 4.
  3. terraform-and-iac-guide, Module 1 (NIEVA) — the origin of LocalStack's dummy test/test credentials, inherited unchanged in this lesson.