Module 2: Anatomy Of A Github Actions Workflow
6. Hands-on: simulating events with `act -e`
Description
So far, every act push or act pull_request you ran used the synthetic event act generates by default —enough to confirm a trigger works, but with no real detail from a specific Pull Request: no number, no branch, no title. This lesson teaches you to write an event's payload by hand, in a JSON file, and pass it to act with -e — the technique that makes it possible to simulate a complete Pull Request without a GitHub account, without a remote repository, and without anyone else involved. This also starts building, inside andes-cargo-infra/, the .github/act-events/ folder you're going to keep using for the rest of this guide.
Connection to the module
This is the module's first lesson that works directly inside andes-cargo-infra/ —the same repository you left prepared in Module 1, lesson 8— not in a disposable lab. Everything you add here stays, permanently, as part of the real project. Lesson 7 does the same with secrets; lesson 8 —this module's project— brings both techniques together in the first workflow that actually touches infrastructure.
Analogy: the rehearsal script, not the live show
A real GitHub event —someone opening a Pull Request on github.com— is like a live theater performance: it happens once, with real actors, at a specific moment. act -e file.json is the scripted rehearsal: you give the cast (act) the exact text of what "the actor" (GitHub) would say in that scene —who opened the PR, from what branch, with what title— and the cast performs the whole scene, with the same seriousness as the real show, without ever needing the live performance to have happened even once.
Step 1 — Write pr-event.json by hand
Stand in andes-cargo-infra/ —the same project from Module 1— and create the folder where every hand-written event in this guide is going to live:
cd andes-cargo-infra
mkdir -p .github/act-events
A real GitHub pull_request event carries dozens of fields. This guide doesn't need to reproduce them all —only the ones a typical workflow in this guide is going to read—: the PR number, the source and target branches, the title, and who opened it. .github/act-events/pr-event.json:
{
"action": "opened",
"number": 42,
"pull_request": {
"number": 42,
"title": "Add tags to the shipment documents bucket",
"head": {
"ref": "feature/add-shipment-tags",
"sha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
},
"base": {
"ref": "main",
"sha": "0f1e2d3c4b5a6978869fedcba0987654321fedc"
},
"html_url": "https://github.com/andes-cargo/andes-cargo-infra/pull/42",
"user": {
"login": "andes-cargo-dev"
}
},
"repository": {
"name": "andes-cargo-infra",
"full_name": "andes-cargo/andes-cargo-infra",
"default_branch": "main"
}
}
Notice that every value is literal, written by hand, with no dependency on any external API: PR #42, source branch feature/add-shipment-tags, target branch main. These numbers and names are going to keep showing up throughout this guide — every time you see "PR #42" or feature/add-shipment-tags, this same file is what defines them. Unlike a real GitHub PR —where the server assigns the number non-deterministically, based on how many PRs existed before— this file is completely reproducible: your PR #42 and anyone else's who follows this guide are going to be exactly the same, always.
Step 2 — A workflow that just prints the payload
This workflow doesn't touch business infrastructure — it's, deliberately, the simplest possible tool to confirm the event arrived complete. .github/workflows/print-event.yml:
name: print-event-payload
on:
pull_request:
jobs:
print-payload:
runs-on: ubuntu-latest
steps:
- name: Show key fields from the pull_request event
run: |
echo "Event name: ${{ github.event_name }}"
echo "PR number: ${{ github.event.pull_request.number }}"
echo "Head branch: ${{ github.event.pull_request.head.ref }}"
echo "Base branch: ${{ github.event.pull_request.base.ref }}"
echo "PR title: ${{ github.event.pull_request.title }}"
- name: Show the full raw payload
run: cat "$GITHUB_EVENT_PATH"
Notice two different ways of reading the same event: github.event.pull_request.number (GitHub Actions' context syntax, for individual fields inside a ${{ }} expression) and $GITHUB_EVENT_PATH (an environment variable pointing at the complete event JSON file, on disk, inside the runner — useful when you need to process the entire payload with a tool like jq, not just one isolated field).
Step 3 — act pull_request -e, with your own payload
act -l
What to expect (literal output, executed to write this lesson):
Stage Job ID Job name Workflow name Workflow file Events
0 print-payload print-payload print-event-payload print-event.yml pull_request
act pull_request -e .github/act-events/pr-event.json
What to expect (literal output, executed to write this lesson):
[print-event-payload/print-payload] ⭐ Run Set up job
[print-event-payload/print-payload] 🚀 Start image=catthehacker/ubuntu:act-latest
[print-event-payload/print-payload] ✅ Success - Set up job
[print-event-payload/print-payload] ⭐ Run Main Show key fields from the pull_request event
[print-event-payload/print-payload] | Event name: pull_request
[print-event-payload/print-payload] | PR number: 42
[print-event-payload/print-payload] | Head branch: feature/add-shipment-tags
[print-event-payload/print-payload] | Base branch: main
[print-event-payload/print-payload] | PR title: Add tags to the shipment documents bucket
[print-event-payload/print-payload] ✅ Success - Main Show key fields from the pull_request event [68.8125ms]
[print-event-payload/print-payload] ⭐ Run Main Show the full raw payload
[print-event-payload/print-payload] | {
[print-event-payload/print-payload] | "action": "opened",
[print-event-payload/print-payload] | "number": 42,
[print-event-payload/print-payload] | "pull_request": {
[print-event-payload/print-payload] | "number": 42,
[print-event-payload/print-payload] | "title": "Add tags to the shipment documents bucket",
[print-event-payload/print-payload] | "head": {
[print-event-payload/print-payload] | "ref": "feature/add-shipment-tags",
[print-event-payload/print-payload] | "sha": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0"
[print-event-payload/print-payload] | },
[print-event-payload/print-payload] | "base": {
[print-event-payload/print-payload] | "ref": "main",
[print-event-payload/print-payload] | "sha": "0f1e2d3c4b5a6978869fedcba0987654321fedc"
[print-event-payload/print-payload] | },
[print-event-payload/print-payload] | "html_url": "https://github.com/andes-cargo/andes-cargo-infra/pull/42",
[print-event-payload/print-payload] | "user": {
[print-event-payload/print-payload] | "login": "andes-cargo-dev"
[print-event-payload/print-payload] | }
[print-event-payload/print-payload] | },
[print-event-payload/print-payload] | "repository": {
[print-event-payload/print-payload] | "name": "andes-cargo-infra",
[print-event-payload/print-payload] | "full_name": "andes-cargo/andes-cargo-infra",
[print-event-payload/print-payload] | "default_branch": "main"
[print-event-payload/print-payload] | }
[print-event-payload/print-payload] | }
[print-event-payload/print-payload] ✅ Success - Main Show the full raw payload [67.267042ms]
[print-event-payload/print-payload] 🏁 Job succeeded
Compare this output with what you'd get from act pull_request without the -e flag (the synthetic event from Module 1/the triggers lesson): there, github.event.pull_request.number wouldn't exist —act's default event doesn't include a complete pull_request object, with number, branches, and title. The -e flag is what turns a generic simulation ("some kind of PR happened") into a specific one ("PR #42, from this exact branch, with this exact title") — the level of detail a real workflow needs to make decisions (for example, commenting on the right PR, or using the PR number as part of a temporary resource's name).
Commit this progress —remember .github/act-events/ isn't a GitHub standard; it's this guide's own convention for keeping hand-written events organized:
git add -A
git commit -m "Add pr-event.json and print-event.yml to practice act -e"
Going deeper: -e works for any event, not just pull_request
Although this lesson shows it with pull_request —this guide's most useful case, because a real PR carries a lot of context worth simulating precisely— the -e file.json mechanism is generic: you could write a push-event.json with a specific commit message, or a workflow_dispatch-event.json with custom inputs for a workflow that uses them. The exact JSON format depends on which event you're simulating — GitHub Actions' official documentation (Resources, below) documents each event type's complete shape, field by field, if you ever need a field this lesson didn't include.
Common mistakes
Writing a pr-event.json with fields missing that a real workflow needs (structure-based). What happens: someone writes an event with only {"number": 42}, without the complete nested pull_request object, and the workflow fails trying to read github.event.pull_request.head.ref. Why it happens: it's not obvious, without seeing the documentation, that GitHub nests almost all the real detail inside a pull_request object, not at the event's root. How to spot it: a step expecting a value from github.event.pull_request.something receives it empty, with no explicit YAML error —the value simply comes out blank. How to fix it: compare your event against the real structure GitHub Actions documents for the event type you're simulating (Resources, below), or against this lesson's complete pr-event.json as a template.
Forgetting -e and wondering why the event "has no detail" (flow-based). What happens: someone runs act pull_request without -e file.json, sees the job run, and is surprised when github.event.pull_request.number comes out empty. Why it happens: act pull_request alone is a valid command —it runs the default synthetic event— so there's no error warning about the oversight. How to spot it: if your PR context variables (number, head.ref, etc.) come out empty without the job failing. How to fix it: check that the command explicitly includes -e path/to/event.json — without that flag, act has no way of knowing a file with the detail you expect exists.
Exercises
Exercise 1 — Extend the event with a new field. Without looking at this lesson, add a "labels": [{"name": "infrastructure"}] field inside your own pr-event.json's pull_request object, and write the ${{ }} expression you'd use in a step to read the first label's name.
See solution
The expression would be ${{ github.event.pull_request.labels[0].name }} — labels is an array (notice the [] brackets in the JSON), so it's accessed by index, starting at 0, just like in most programming languages. The general pattern: every level of nesting in the event's JSON is reflected exactly in the github.event... expression's path.
Exercise 2 — Explain why PR #42 is reproducible. A colleague, who followed this same guide on a different machine, shows you their own pr-event.json — and it also says "PR #42." Coincidence, or something else? Explain in one sentence.
See solution
It's not a coincidence — it's determinism by design. Unlike a real GitHub PR, where the server assigns the number based on how many PRs existed before in that specific repository (which varies from repository to repository), the number 42 in this pr-event.json is a value the student wrote by hand, following this lesson's file exactly. Anyone following this guide is going to write the same 42, because there's no external API generating that number — it's in the lesson's text, period.
Exercise 3 — Diagnose an empty field. A coworker runs their workflow with act pull_request -e pr-event.json and github.event.pull_request.title comes out empty, even though the job runs with no errors. What are the two most likely causes, based on what you saw in "Common mistakes"?
See solution
The two most likely causes: (1) their pr-event.json doesn't have the title field inside the pull_request object —a typical incomplete-structure mistake— or (2) the field exists, but is nested incorrectly —for example, at the JSON's root instead of inside pull_request. In both cases, the job doesn't fail, because reading a nonexistent field from a JSON object inside a ${{ }} expression simply produces an empty string, not an error — which is why this kind of problem goes unnoticed if you don't check the output carefully.
Summary and next step
In this lesson you wrote pr-event.json by hand —PR #42, feature/add-shipment-tags → main, completely deterministic— and used it with act pull_request -e to run a workflow that printed every field of the payload, confirmed with literal output. You also left, inside andes-cargo-infra/, .github/act-events/'s first piece, one of this guide's own conventions you're going to keep using in the following modules.
Before moving on you should be able to: write a minimal but complete pull_request event, from memory; explain the difference between act pull_request with and without -e; and locate any field of a real event inside an expression's github.event.... syntax.
You have events solved. Lesson 7 closes out the pair of simulation techniques you're missing: passing secrets to act without ever committing them.
Resources
- nektosact.com — User Guide — official documentation for
act -eand custom event simulation. - GitHub Docs — Events that trigger workflows:
pull_requestpayload — the real, complete structure of apull_requestevent's payload, if you need a field this lesson didn't include. - GitHub Docs — Context and expression syntax — how contexts (
github.event, among others) are read inside a${{ }}expression.