GitHub Actions Basics: One CI Check You Can Trust
CI only matters if it runs something real. Most tutorials show either bloated pipelines or toy examples that verify nothing. I prefer one workflow, one runner, two commands, and a green check.
This post is the workflow file I use as a baseline.
Why I set this up first
Without CI, I am the only person verifying the build. The moment another OS, another Node version, or another contributor enters the picture, assumptions break. CI gives me feedback in a clean environment every time I push (Source).
GitHub Actions is just the platform. It spins up a temporary machine, checks out my code, runs the commands I tell it to run, and reports pass or fail (Source). Nothing more mysterious than that.
Here is what GitHub Actions gives you out of the box:
- Temporary runners — clean VMs that are destroyed after each job (Source)
- Event triggers — workflows run on push, pull request, schedule, or manual dispatch (Source)
- Pass/fail reporting — a green check or red X visible on every commit
- Job logs — full stdout/stderr for every step, viewable in the Actions tab
The workflow file
Create .github/workflows/ci.yml:
name: CIon:push:branches: ["main"]pull_request:branches: ["main"]jobs:build:runs-on: ubuntu-lateststeps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with:node-version: 20- run: npm install- run: npm run lint- run: npm run build
That is it. actions/checkout pulls the repo (Source). actions/setup-node installs Node (Source). The three run lines execute the same commands I run locally. If any exit non-zero, the workflow fails (Source).
Here is what each step in the workflow does:
actions/checkout@v4— clones your repository onto the runner so subsequent steps can access your filesactions/setup-node@v4— installs the specified Node.js version (20 in this case) and caches npm dependenciesnpm install— installs all dependencies frompackage.jsonnpm run lint— runs your linter (e.g. ESLint) to catch style and syntax errorsnpm run build— runs your production build to verify the project compiles cleanly
Extending it with AWS
The baseline above is CI only — it verifies the build. When I need to actually deploy, I extend the same workflow with AWS steps. The services I use and why:
- S3 — hosts the static build output. Cheaper and simpler than running a web server. The build artifacts go to a bucket configured for static website hosting.
- CloudFront — CDN in front of S3. Caches at the edge and gives me HTTPS without managing certificates on the origin.
- IAM OIDC — lets GitHub Actions authenticate to AWS without long-lived access keys. You set up an identity provider in IAM, create a role with a trust policy scoped to your repo, and the workflow assumes that role using
aws-actions/configure-aws-credentials.
Here is the deploy portion I add after the build step:
deploy:needs: buildruns-on: ubuntu-latestpermissions:id-token: writecontents: readsteps:- uses: actions/checkout@v4- uses: actions/setup-node@v4with:node-version: 20cache: 'npm'- run: npm ci- run: npm run build- uses: aws-actions/configure-aws-credentials@v4with:role-to-assume: arn:aws:iam::123456789012:role/github-actions-deployaws-region: us-east-1- run: aws s3 sync public/ s3://my-bucket --delete- run: aws cloudfront create-invalidation --distribution-id E1ABCDEF23 --paths "/*"
The permissions: id-token: write line is required for OIDC. Without it, the configure-aws-credentials action cannot request the temporary credentials.
How I verify it works
I commit the file, push to GitHub, then open the Actions tab (Source). If the run is green, CI is working. If it fails, the log tells me exactly which command failed. I fix it locally, commit again, and CI re-runs.
Here is the verification loop:
- Commit the workflow file —
git add .github/workflows/ci.yml && git commit -m "Add CI" - Push to GitHub —
git push origin main - Open the Actions tab — navigate to your repo on GitHub and click the "Actions" tab
- Check the run status — green check means all steps passed; red X means a step failed
- Read the failing step's log — expand the failed step to see the exact error output
- Fix locally, commit, push again — CI automatically re-runs on the new commit
Common failures I have hit
- Workflow never runs: the default branch is not named
main. - Install fails:
package-lock.jsonis missing or out of sync. - Passes locally, fails in CI: local environment hides an assumption, like a different Node version or a missing env var.
Common failures and fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| Workflow never runs | Default branch is not main | Update the branches filter or rename your branch (Source) |
npm install fails | package-lock.json missing or out of sync | Run npm install locally and commit the lock file (Source) |
| Passes locally, fails in CI | Different Node version or missing env var | Pin node-version in setup-node and add required secrets (Source) |
| Lint passes locally, fails in CI | Different ESLint config or plugin version | Ensure .eslintrc and all plugins are committed to the repo |
| Build fails with "command not found" | package.json script is missing | Verify lint and build scripts exist in package.json (Source) |
The fix is always to make local and CI more similar, not to weaken the checks.
Debugging AWS-specific failures
Once the deploy step is in place, a new class of failures shows up:
Not authorized to perform: sts:AssumeRoleWithWebIdentity— the IAM role's trust policy does not include your repo. The trust policy must listrepo:your-org/your-repo:ref:refs/heads/mainin theStringLikecondition. I once spent an hour on this because the condition hadref:refs/heads/masterand the branch wasmain.AccessDeniedons3 sync— the role lackss3:PutObjectands3:DeleteObjecton the bucket. Scope the policy to the bucket ARN, not*.- CloudFront invalidation hangs — invalidating
/*on a large distribution can take 5-10 minutes. If the job times out, scope the invalidation to the paths that actually changed.
Cost considerations
GitHub gives 2,000 free Actions minutes per month on a personal account. A single workflow that runs lint + build takes about 2-3 minutes per run. At 10 pushes a day, that is roughly 900 minutes a month — well within the free tier. The deploy job adds another 2-3 minutes but only runs on pushes to main, so it is less frequent.
On the AWS side, S3 storage for a static site is negligible — a few cents a month for a typical blog. CloudFront costs more if traffic is high, but for a personal site it stays under $1. The real cost trap is forgetting to clean up old S3 object versions if you enable versioning; those accumulate silently.
Alternative approaches I considered
- Netlify deploys instead of AWS. For static sites, Netlify is simpler — no IAM, no bucket, no invalidation. I use it for this blog. The AWS pipeline above is for projects where I need more control over the CDN config or where the client already has an AWS account.
- GitHub Pages. Free and built-in, but no CDN configuration and no custom headers. Fine for docs, not for a production site.
- Self-hosted runners. Only worth it if you are burning through the free minutes or need access to private network resources. For everything I do, the hosted runners are fine.
Closing
A CI setup is not measured by complexity. It is measured by whether it runs my real build in a clean environment and tells me when something breaks. If I have a workflow file, a visible run, and a green check, CI is doing its job.
References
- GitHub Actions — Understanding GitHub Actions
- GitHub Actions — About GitHub-hosted runners
- GitHub Actions — Events that trigger workflows
- GitHub Actions — Using jobs in a workflow
- GitHub Actions — Viewing workflow run history
- GitHub Actions — Workflow syntax (env)
- GitHub Actions — actions/checkout
- GitHub Actions — actions/setup-node
- GitHub — Managing branches in your repository
- npm — package-lock.json docs
- npm — Using npm scripts