:bulb: When you first meet GitHub Actions, the YAML can be opaque. This post is a learning note that dissects two real workflows in the same repository line by line. (1) deploy.yml — multi-job workflow that builds and deploys a VitePress site, triggered by push. (2) stale.yml — single-action bot that runs daily via schedule cron to clean up inactive issues/PRs. Comparing the two makes the difference between trigger types and job structures crystal clear.


[01] GitHub Actions in One Sentence

“A service where, when an event (push, PR, schedule, etc.) happens in your repository, GitHub automatically runs commands written in your YAML file on a virtual machine they lend you.”

  • What you care about: write one YAML file and push to .github/workflows/
  • What GitHub handles: watch the repo → match events → allocate a runner → run commands → display results (Actions tab)

[02] Five Core Terms

Term One-line definition Example in this post
Workflow One automation scenario (= one YAML file) Deploy VitePress site to Pages / Close stale issues and PRs
Event A signal that triggers a workflow push, workflow_dispatch, schedule
Job A bundle of tasks running on the same runner build, deploy, stale
Step One command executed sequentially within a job npm ci, npm run docs:build
Action A pre-built, reusable “step” package actions/checkout@v4, actions/stale@v3

Unix analogy: workflow = shell script, job = function, step = single command, action = external library

:bulb: With these 5 words in your head, you can read 70% of any workflow YAML.


[03] File Location

1
2
3
4
5
repo root/
└── .github/
    └── workflows/
        ├── deploy.yml      ← workflow ①
        └── stale.yml       ← workflow ②

GitHub automatically watches this folder. Any YAML added is immediately recognized, and triggers fire from the next matching event.

:warning: Note the folder name is .github/workflows/ (plural). .github/workflow/ is ignored.


[04] Workflow ① — deploy.yml (push-triggered build/deploy)

Triggered on push to main: builds the VitePress site and publishes to GitHub Pages.

4-1. Header Comment

1
2
3
4
# ============================================================
# deploy.yml: VitePress GitHub Pages auto-deploy
# On push to main: build docs/.vitepress/dist and deploy to Pages
# ============================================================
  • Lines starting with # are YAML comments. No effect on execution.
  • Meta info that lets a human grasp the file’s purpose in 3 seconds.

4-2. Workflow Name

1
name: Deploy VitePress site to Pages
  • Shown in the workflow list in the Actions tab.
  • If omitted, the filename is displayed. A one-line descriptive name is recommended for UI readability.

4-3. Trigger — on: (push + manual)

1
2
3
4
on:
  push:
    branches: [main]
  workflow_dispatch:
Line Meaning
on: Root key declaring “what events should trigger this workflow”
push: git push event
branches: [main] Only when push lands on main (other branches are ignored)
workflow_dispatch: Adds a manual run button to the Actions UI (for debugging/redeploy)

:warning: git push origin feature/foo does NOT trigger — must be merged to main first.

4-4. Permissions — permissions:

1
2
3
4
permissions:
  contents: read
  pages: write
  id-token: write

GitHub Actions uses an auto-issued GITHUB_TOKEN to access the GitHub API. This block declares minimal permissions for that token.

Permission Why needed
contents: read Read access required to checkout the repo
pages: write Write access required to publish to GitHub Pages
id-token: write Issues an OIDC token (required by the official Pages action)

:warning: Without id-token: write, actions/deploy-pages@v4 fails authentication. Required for Pages deployment.

4-5. Concurrency Control

1
2
3
concurrency:
  group: pages
  cancel-in-progress: false
Line Meaning
group: pages Workflows sharing this group name run one at a time
cancel-in-progress: false If a deploy is in progress, don’t cancel — wait until it finishes

:bulb: Even with two rapid pushes, the second deploy starts after the first finishes → prevents tangled Pages artifacts.

4-6. Jobs — build job

1
2
3
4
5
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      ...
Line Meaning
jobs: Starts all job definitions for this workflow
build: Job ID — referenced by other jobs via needs: build
runs-on: ubuntu-latest OS for the VM this job runs on — Ubuntu latest LTS

Step ① Checkout

1
2
3
4
- name: Checkout
  uses: actions/checkout@v4
  with:
    fetch-depth: 0
Line Meaning
- name: Checkout Step display name (shown in Actions UI)
uses: actions/checkout@v4 Official checkout action v4 — clones the repo to the runner
fetch-depth: 0 Fetch full history (required by builds using git metadata like sitemap/last-modified)

:bulb: Default is fetch-depth: 1 (last commit only). For VitePress sitemap, Jekyll’s git-authors-plugin, etc., 0 (all history) is safe.

Step ② Setup Node

1
2
3
4
5
- name: Setup Node
  uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
Line Meaning
actions/setup-node@v4 Official action installing Node.js on the runner
node-version: 20 LTS version (match package.json recommended version)
cache: npm Caches ~/.npm in GitHub cache → speeds up npm ci on next runs

Step ③ Install Dependencies

1
2
- name: Install dependencies
  run: npm ci
Item Meaning
run: Run a command directly in the runner shell (contrasts with uses:)
npm ci Clean install based on package-lock.json — ensures build reproducibility. Stricter and faster than npm install

Step ④ VitePress Build

1
2
- name: Build with VitePress
  run: npm run docs:build
  • Invokes package.json’s "docs:build": "vitepress build docs"
  • Output: docs/.vitepress/dist/ — static HTML/CSS/JS

Step ⑤ Configure Pages

1
2
- name: Setup Pages
  uses: actions/configure-pages@v4
  • Reads the repo’s Pages settings and injects env vars (e.g., base path)
  • Fails with HttpError: Not Found here if Pages isn’t enabled on the repo
  • Fix: Settings → Pages → Source = "GitHub Actions"

Step ⑥ Upload Artifact

1
2
3
4
- name: Upload artifact
  uses: actions/upload-pages-artifact@v3
  with:
    path: docs/.vitepress/dist
Line Meaning
actions/upload-pages-artifact@v3 Packages and uploads build output as a Pages-specific artifact
path: docs/.vitepress/dist Upload target (= VitePress build output)

:warning: This is different from generic actions/upload-artifact. Pages deployment only accepts this dedicated artifact.

4-7. Jobs — deploy job

1
2
3
4
5
6
7
8
9
10
deploy:
  needs: build
  runs-on: ubuntu-latest
  environment:
    name: github-pages
    url: $
  steps:
    - name: Deploy to GitHub Pages
      id: deployment
      uses: actions/deploy-pages@v4
Line Meaning
needs: build Runs only after build job succeeds (auto-skipped on failure)
environment: Registers deploy target — manage approval policies/secrets in Settings → Environments
name: github-pages Reserved environment name auto-recognized by Pages (don’t change)
url: $ Site URL shown in Actions UI after deploy — references the step output below
id: deployment Step identifier — referenced as steps.deployment.outputs.page_url
actions/deploy-pages@v4 Pulls the artifact uploaded by the previous job and actually publishes to Pages

4-8. deploy.yml Execution Flow

flowchart TD
    A["git push origin main"] --> B{"on.push.branches
= main?"} B -->|No| Z["Ignored"] B -->|Yes| C["Allocate runner
ubuntu-latest"] C --> D1["build: checkout"] D1 --> D2["build: setup-node 20"] D2 --> D3["build: npm ci"] D3 --> D4["build: vitepress build"] D4 --> D5["build: configure-pages"] D5 --> D6["build: upload-pages-artifact
(upload dist)"] D6 --> E{"build succeeded?"} E -->|No| F["Skip deploy"] E -->|Yes| G["deploy: deploy-pages
publishes artifact to Pages"] G --> H["https://username.github.io updated"] style B fill:#fff3e0,stroke:#e65100 style E fill:#fff3e0,stroke:#e65100 style Z fill:#f5f5f5,stroke:#616161 style F fill:#ffcccc,stroke:#c62828 style H fill:#e8f5e9,stroke:#2e7d32

[05] Workflow ② — stale.yml (schedule-triggered bot)

A second workflow in the same repo. Runs daily to clean up inactive issues and PRs.

5-1. What stale.yml Does in One Sentence

“A bot workflow that runs daily at 01:30 UTC, labels issues/PRs with no recent activity as Status: Stale, and auto-closes them 7 days later if still inactive.”

Removes accumulated “old, unanswered issues/PRs” without manual effort. Used by popular open-source repos like the minimal-mistakes Jekyll theme to prevent issue trackers from exploding.

5-2. How It Differs from deploy.yml

Aspect deploy.yml stale.yml
Purpose Site deployment Repo management
Trigger on: push + workflow_dispatch on: schedule (cron)
Run time When someone pushes Fixed time, GitHub auto-runs
Main action actions/checkout + actions/deploy-pages Single actions/stale
Job count 2 (build, deploy) 1 (stale)
Artifact _site / dist (static files) None — only GitHub API calls

Key learning points: how schedule triggers work and structure of single-action workflows like actions/stale.

5-3. Full Code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
name: "Close stale issues and PRs"
on:
  schedule:
    - cron: "30 1 * * *"

jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v3
        with:
          stale-issue-message: |
            This issue has been automatically marked as stale because it has not had recent activity.

            If this is a **bug** and you can still reproduce this error on the `master` branch, please reply with any additional information you have about it in order to keep the issue open.

            This issue will automatically be closed in 7 days if no further activity occurs. Thank you for all your contributions.
          stale-pr-message: |
            This pull request has been automatically marked as stale because it has not had recent activity.

            This pull request will automatically be closed in 7 days if no further activity occurs. Thank you for all your contributions.
          stale-issue-label: "Status: Stale"
          exempt-issue-labels: "Status: Accepted,Status: Under Consideration,Status: Review Needed,Status: In Progress"
          stale-pr-label: "Status: Stale"
          exempt-pr-labels: "Status: Accepted,Status: Under Consideration,Status: Review Needed,Status: In Progress"

28 lines total. Five sections make the structure clear.

5-4. Trigger — on: schedule + cron

1
2
3
on:
  schedule:
    - cron: "30 1 * * *"
Line Meaning
on: Trigger declaration (same key as deploy.yml)
schedule: Time-based trigger — clock fires instead of push/PR
- cron: "30 1 * * *" cron expression. Runs daily at 01:30 UTC

Reading 5-Field cron

1
2
3
4
5
6
7
cron: "30 1 * * *"
       │  │ │ │ │
       │  │ │ │ └─ day of week (0=Sun~6=Sat, * = any)
       │  │ │ └─── month (1~12, * = any)
       │  │ └───── day of month (1~31, * = any)
       │  └─────── hour (0~23 UTC)
       └────────── minute (0~59)
Field Value Meaning
Minute 30 30
Hour 1 1 (UTC)
Day * Every day
Month * Every month
Day of week * All days

Result: Runs at 01:30 UTC daily = 10:30 KST (UTC+9).

:warning: GitHub Actions’ schedule: is always UTC. To run “daily at 09:00 KST”, write 0 0 * * * (00:00 UTC = 09:00 KST).

:bulb: cron times may be delayed by ±10 minutes — GitHub queues jobs based on runner availability. Not suitable for minute-precise tasks.

Common cron Patterns

cron Meaning
0 * * * * Every hour on the hour
*/15 * * * * Every 15 minutes
0 0 * * * Daily midnight UTC (= 09:00 KST)
0 0 * * 0 Sunday midnight UTC
0 0 1 * * First day of each month, midnight UTC

5-5. Jobs — stale job

1
2
3
4
5
jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v3
Line Meaning
jobs: Start of all job definitions
stale: Job ID — single word is enough (no other job references it)
runs-on: ubuntu-latest Ubuntu latest LTS runner. This workflow only calls GitHub API, so OS doesn’t really matter
- uses: actions/stale@v3 Invoke actions/stale v3. This one line is almost the entire workflow

:bulb: Compared to deploy.yml’s build job, the step has no name: field (Actions UI shows the action name directly). Common omission when a job has only one step.

What actions/stale@v3 Does

Internally:

  1. Fetch issue/PR list — REST API to list all open issues/PRs
  2. Check activity — based on updated_at, candidates are those with no change for 60+ days (default)
  3. Check exempt labels — skip if any exempt-issue-labels are attached
  4. Mark stale — add stale-issue-label and post stale-issue-message comment
  5. Close — if stale-labeled and no activity for another 7 days (default), auto-close

Steps 3, 4, 5 are controlled by with: block options below.

5-6. Stale Messages — Multi-line String in with: Block

1
2
3
4
with:
  stale-issue-message: |
    This issue has been automatically marked as stale because it has not had recent activity.
    ...

YAML Multi-line — | Meaning

| (block scalar / literal style) preserves line breaks. Indentation is stripped based on the first line, and blank lines/markdown emphasis are kept.

1
2
3
4
5
key: |
  Line 1
  Line 2

  Line 4 (after blank)

→ Result:

1
2
3
4
Line 1
Line 2

Line 4 (after blank)

Difference from >

Notation Behavior Use case
key: \| Preserve line breaks Here — markdown messages, multi-line code
key: > Line breaks → spaces (folded) Long single sentences
key: "..." Single-line string Short values

actions/stale messages require blank lines and markdown (**bug**, [link](URL)), so | is required.

:bulb: stale-issue-message is what the bot auto-posts as a comment. It’s the interface telling users “why this became stale and what to do”. When adopting this workflow, customize tone, language, and contact channels.

5-7. Label Policy

1
2
3
4
stale-issue-label: "Status: Stale"
exempt-issue-labels: "Status: Accepted,Status: Under Consideration,Status: Review Needed,Status: In Progress"
stale-pr-label: "Status: Stale"
exempt-pr-labels: "Status: Accepted,Status: Under Consideration,Status: Review Needed,Status: In Progress"
Option Role
stale-issue-label Label name applied to stale-judged issues
exempt-issue-labels If any of these labels exist, exempt from stale processing (comma-separated)
stale-pr-label Stale label for PRs
exempt-pr-labels Exempt labels for PRs

Why Exempt Labels Matter

Issues/PRs with these 4 labels are never auto-closed due to inactivity:

Label Meaning
Status: Accepted Maintainer already accepted to work on it → safe to leave
Status: Under Consideration Under review → hard to decide quickly
Status: Review Needed Code review pending → maintainer-side responsibility
Status: In Progress In progress → keep alive despite time

:warning: Without these safeguards, meaningful long-tracked issues get cut down too. Defining exempt labels is essential when adopting stale.

5-8. stale.yml Execution Flow

flowchart TD
    A["GitHub scheduler
daily 01:30 UTC"] --> B["Allocate runner
ubuntu-latest"] B --> C["Run actions/stale@v3"] C --> D["GitHub API:
fetch open issues/PRs"] D --> E{"updated_at
≥ 60 days ago?"} E -->|No| F["Next item"] E -->|Yes| G{"Exempt label
attached?"} G -->|Yes| F G -->|No| H{"Already has
stale label?"} H -->|No| I["Add stale label
+ post guidance comment"] H -->|Yes, 7 days passed| J["Auto-close issue/PR"] H -->|Yes, <7 days| F style A fill:#e3f2fd,stroke:#1565c0 style I fill:#fff3e0,stroke:#e65100 style J fill:#ffcccc,stroke:#c62828 style F fill:#f5f5f5,stroke:#616161

[06] Comparing the Two Workflows

How the same 5 terms (workflow / event / job / step / action) express two different workflows:

Aspect deploy.yml stale.yml
Event push + workflow_dispatch schedule (cron)
Trigger source Human (push) GitHub scheduler (clock)
Permissions Explicit (contents, pages, id-token) Default GITHUB_TOKEN (not specified)
Concurrency group: pages, cancel-in-progress: false Not specified (no deploy concurrency issue)
Job count 2 (builddeploy, with needs:) 1 (stale)
Step count ~7 (checkout/setup-node/npm ci/build/configure-pages/upload/deploy) 1 (uses: actions/stale@v3)
Main actions actions/checkout, actions/setup-node, actions/configure-pages, actions/upload-pages-artifact, actions/deploy-pages Just actions/stale
Behavior Runs build commands on runner (run: npm ci) Only GitHub API calls (no external commands)
Output Static site (docs/.vitepress/dist) None (only issue/PR state changes)
Failure impact Site not updated Just misses one day’s cleanup

Same Mechanism

1
2
3
4
5
6
7
8
9
10
11
Common structure:
.github/workflows/<file>.yml
│
├─ name:                    # ← both have it
├─ on:                      # ← both have it (different values)
│
└─ jobs:                    # ← both have it
   └─ <job-id>:
       runs-on: ubuntu-latest
       steps:
         - ...              # ← both have it (different content)

Every workflow can be expressed with these 5 terms. Site deployment, issue cleanup, test automation, releases, secret rotation — all on this same structure.


[07] Common Errors and Pitfalls

7-1. Three Common deploy.yml Errors

HttpError: Not Found (configure-pages step)

  • Cause: Pages not enabled, or Source set to Deploy from a branch
  • Fix: Settings → Pages → Source = GitHub Actions

Resource not accessible by integration

  • Cause: Missing pages: write or id-token: write in permissions:
  • Fix: Specify all 3 permissions from 4-4

CSS/Images Broken (404)

  • Cause: Project Pages (username.github.io/repo-name/) without base config
  • Fix: Add base: '/repo-name/' to docs/.vitepress/config.ts
    • For Jekyll: add baseurl: "/repo-name" to _config.yml

7-2. Three Common stale.yml Pitfalls

cron Is UTC, Not KST

1
2
3
4
5
# WRONG — "want to run at 9 AM KST daily"
- cron: "0 9 * * *"   # actually UTC 09:00 = KST 18:00

# CORRECT
- cron: "0 0 * * *"   # UTC 00:00 = KST 09:00

GitHub Actions has no timezone option. Convert to UTC.

schedule Doesn’t Run on Forks

schedule: is auto-disabled on forks. Otherwise, fork cron would cause unintended load across GitHub. For private forks, use workflow_dispatch: or external schedulers.

Default 60/7 Days Are Used If Not Specified

1
2
3
4
# Defaults not shown in stale.yml
days-before-stale: 60         # days of no activity before stale
days-before-close: 7          # days after stale before close
operations-per-run: 30        # max items per run

For low-activity repos, 60 days may be too short. Safer to specify explicitly.

1
2
3
with:
  days-before-stale: 180       # 6 months of inactivity → stale
  days-before-close: 14        # +2 weeks → close

[08] Debugging Tips

Situation Approach
Workflow doesn’t trigger Check file location (.github/workflows/), extension (.yml), and on: block
Only one step fails Click the step in Actions UI → expand console logs
Schedule doesn’t fire Check if repo is a fork / repos inactive for 60+ days have some schedules auto-disabled
Reproduce locally Use act to run workflows in local Docker
Need secrets (API keys, etc.) Register at Settings → Secrets and variables → Actions and reference as $
Manual test run If workflow_dispatch: is set, Run workflow button appears in Actions UI

[09] One-Page Summary

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
.github/workflows/
│
├── deploy.yml                                   ← push-triggered build/deploy
│   │
│   ├─ on: push(main) | workflow_dispatch        # ① when?
│   ├─ permissions: contents:r pages:w id-token:w
│   ├─ concurrency: group=pages
│   │
│   └─ jobs:
│      ├─ build:                                 # ② what?
│      │   1. checkout (full history)
│      │   2. setup-node 20 + npm cache
│      │   3. npm ci
│      │   4. npm run docs:build  → docs/.vitepress/dist
│      │   5. configure-pages
│      │   6. upload-pages-artifact (dist)
│      │
│      └─ deploy: needs=build
│          1. deploy-pages → https://*.github.io updated
│
└── stale.yml                                    ← schedule-triggered bot
    │
    ├─ on: schedule (cron: 30 1 * * * = daily 01:30 UTC)
    │
    └─ jobs:
       └─ stale: ubuntu-latest
           └─ uses: actions/stale@v3
               with:
                 stale-*-message:    "..."        # guidance message
                 stale-*-label:      "Status: Stale"
                 exempt-*-labels:    "Accepted, ..."

Whether triggered by one push or one clock tick — GitHub Actions runs both workflows on the same model: event → allocate runner → sequential step execution.

:star: Jekyll, Hugo, Next.js, Astro and other SSGs can reuse the deploy.yml pattern by swapping build commands and artifact paths. Beyond issue/PR cleanup, secret rotation, release notes generation, and DB backups all fit the stale.yml schedule pattern. 5 terms + 2 patterns (event-driven/schedule-driven) cover almost every automation scenario.