Creating a GitHub Actions workflow with push, pull request, and workflow_dispatch triggers. Then separating the ci job for lint and build from the release job that only runs on main and staging, plus managing secrets such as GITHUB_TOKEN and NPM_TOKEN.

In episode 5 the semantic-release configuration was ready and verified with a dry run. Now we put it in the right place: the CI pipeline. Running npx semantic-release manually on a local machine isn't a production flow — a release must run in a consistent CI environment, with secure tokens, and only be triggered by the right events.
GitHub Actions is the most natural place for this because semantic-release and GitHub live in the same ecosystem: tags, releases, and PR comments are created by a bot directly in the same repository. This episode builds two things: the ci job for quality assurance, and the release job that only runs on the main and staging branches.
A workflow is a YAML file in the .github/workflows/ folder. It has three major parts: on for triggers, jobs for work, and permissions for access rights. Semantic-release needs the full git history to compare commits since the last tag, so checkout must include fetch-depth: 0.
| Trigger | When It Runs |
|---|---|
push | Every commit pushed to a specific branch |
pull_request | A PR is opened, updated, or reviewed |
workflow_dispatch | Triggered manually from the Actions tab |
The division of labor is natural: pull_request and push trigger the ci job for validation, while release only happens on push to main or staging.
name: CI & Release
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
jobs:
ci:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm run lint
- run: npm run build
- name: Semantic Release Dry Run
if: github.event_name == 'pull_request'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release --dry-run --no-ci
release:
if: github.event_name == 'push'
runs-on: ubuntu-latest
needs: ci
permissions:
contents: write
issues: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Semantic Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: npx semantic-releaseLet's break it down line by line:
ci runs for pull requests and pushes, with the minimal access of contents: read — enough for checkout and build. The last step runs a dry run so pull requests show the version that would be released.release only runs on a push event, waits for the ci job to finish (needs), and uses contents: write because it must create tags and GitHub Releases.fetch-depth: 0 fetches the entire git history — an absolute prerequisite so the commit analyzer can compare commits since the last tag.branches on the push trigger and the branches list in release.config.cjs — two layers of protection so a release can't happen from the wrong branch.If you want to be more explicit, the release job can be given a condition based on the branch name. Values like github.ref are referenced inside YAML — not in prose:
jobs:
release:
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging'This kind of condition makes stable releases happen only from main and prereleases only from staging, whatever trigger arrives.
Secrets are never hardcoded in YAML — their values are fetched at runtime:
GITHUB_TOKEN: an automatic token GitHub provides for every run. Its default behavior is contents: read; to create tags and releases, the job must raise it to contents: write.NPM_TOKEN: a token from npmjs.com for publishing packages. Registered in the repository Settings, then referenced via secrets.NPM_TOKEN.NPM_CONFIG_REGISTRY: an environment variable for directing publishes to a private registry.Open repository -> Settings -> Secrets and variables -> Actions
click New repository secret
enter the name (e.g. NPM_TOKEN) and its value
the secret is used via env, never shown in logsWarning
Forgetting to raise contents: write is the most common failure cause: semantic-release successfully computes the version and changelog, then fails with 403 when creating the tag or GitHub Release. Make sure the release job has permissions: contents: write, and other jobs are fine with just contents: read (least privilege).
workflow_dispatch lets you trigger the workflow manually from the Actions tab — useful for trying out configuration without a new commit.DEBUG environment variable to semantic-release:* on the job.feat or fix commit since the last tag, so there's genuinely nothing to release.In episode 6 you built:
push, pull_request, and workflow_dispatch triggers.ci job for lint, build, and dry run with minimal access.release job gated to the main and staging branches with permissions: contents: write.GITHUB_TOKEN and NPM_TOKEN secrets.Your automation loop is now complete: well-formed commits, structured branches, tested configuration, and a pipeline ready to release. In episode 7 we'll compare the staging vs production flow: how rc releases run on staging and stable releases on main, plus how to validate both with a dry run before a real release. See you in episode 7!