Craftsperson's Guide to GitHub Actions #3: Building and Releasing
In the previous chapter, we gained confidence through comprehensive testing, including unit tests, property-based tests, and mutation tests. Our action quality is rock-solid.
But quality means nothing until the product reaches users. An action that only exists on your machine is just an expensive science experiment. In this final chapter, we'll build for production and create an automated release pipeline that safely delivers your action to the world.
Building Your Action
For JavaScript-based GitHub Actions, we need a build process. GitHub doesn't compile or bundle the code before execution, which means we must ship a ready-to-run artefact.
This means transpiling TypeScript to JavaScript, bundling all dependencies, and checking the build artefact into Git. Yes, you read that correctly. Unlike typical projects where you ignore your build artefacts from version control, GitHub Actions requires you to commit them to the repository.
Use whichever bundler you prefer. The example repository utilises Bun for its excellent bundling feature, but esbuild or Rolldown can also be used with similar results.
As discussed in Chapter 1, keep your action entry point separate from source files and tests. This separation makes configuring bundling easier.
The key is defining a reproducible build command that works identically everywhere — on your laptop, on your colleague's machine, in GitHub Actions. I use Taskfile for orchestration, but npm scripts, Makefiles, or shell scripts are equally valid choices. Pick your tool; just make it consistent.
build:
desc: Build GitHub Action
sources:
- bin/**/*.ts
- src/**/*.ts
generates:
- dist/index.js
cmd: >
bun build bin/index.ts
--production
--target node
--outdir dist
--format esmThis builds our TypeScript entry point into a minified Node.js script in modern ESM format.
Running task build produces a 510 KB bundle containing all dependencies. That might seem large for a simple ROT-13 action, but GitHub Actions runners download it in no time.
The hardest part is to remember to commit the build artefact. Being human — and thus forgetful — we automate this with a pre-commit hook using Husky.
task -p lint build
git add dist README.mdThis hook runs linting and building in parallel, then stages both the dist directory and README.md.
Why the README? We generate action documentation during the build using action-docs, keeping documentation synchronised with code.
Building and committing is just the first step. Now we need to verify the action actually works and release it safely.
Trust, But Verify the Action
Our CI/CD pipeline runs the same tests you run locally: unit tests, property-based tests, and mutation tests. But that's not enough.
Despite covering a lot of ground with existing tests, we still need to verify the action works in its actual runtime environment: GitHub Actions. Let's create an acceptance testing workflow. It's verbose, so we'll break it down piece by piece.
name: Acceptance Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
env:
original: "Hello, World!"
transformed: "Uryyb, Jbeyq!"
jobs:
test-unit:
# Unit, property-based, and mutation tests
...
test-local-action:
name: Test local action
permissions:
contents: read
strategy:
matrix:
runner: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.runner }}
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Test with valid input
uses: ./
id: valid
with:
string: ${{ env.original }}
- name: Fail if output is incorrect
if: steps.valid.outputs.result != env.transformed
run: |
echo "::error::Expected result of transformation was '${{ env.transformed }}', but got '${{ steps.valid.outputs.result }}'"
exit 1
- name: Test with empty input
uses: ./
id: invalid
continue-on-error: true
with:
string: ""
- name: Fail if empty input succeeds
if: steps.invalid.outcome != 'failure'
run: |
echo "::error::Expected action to fail when given empty input, but it succeeded."
exit 1Why testing matrices? While our action is platform-agnostic, many aren't. File system operations, for instance, often work similarly on Linux and macOS but break on Windows. Developing on a single platform while mocking the entire filesystem effectively hides these issues. Thus, we catch defects before our users do by testing across many platforms.
Since we haven't released the action yet, we use relative notation to reference the repository root. Remember to check out the repository first. GitHub Actions won't do it automatically.
We test both valid input (should succeed) and invalid input (should fail). The assertion steps use conditional execution. They only run when verification fails, resulting in the workflow failing.
Semantic Versioning Done Right
When the verification passes, it’s time to release. Unlike some release processes that feel like organising a conference, releasing GitHub Actions is refreshingly simple: we tag the verified commit and push.
GitHub Actions recommends semantic versioning with a simple twist. Instead of one, we publish three tags for each release:
Full version: v1.2.3 (patch-level precision)
Minor version: v1.2 (minor updates included)
Major version: v1 (the convenient default)
This approach lets users choose their comfort level. Want automatic updates? Use v1. Need stability? Pin to v1.2.3. The major version tag is what most users reference, and we keep it updated automatically.
Here's the workflow:
jobs:
test-unit: ...
test-local-action: ...
release:
name: Release
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [test-unit, test-local-action]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Determine next version
id: version
uses: mathieudutour/github-tag-action@v6.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
default_bump: patch
create_annotated_tag: true
dry_run: true
- name: Release new version
if: steps.version.outputs.new_version != steps.version.outputs.previous_version
run: |
function push() {
local tag="$1"
git tag -fa "$tag" -m "Release $tag"
git push origin "$tag" --force
}
git config user.name "$USERNAME"
git config user.email "$EMAIL"
push "$TAG"
push "$(echo "$TAG" | cut -d . -f 1)"
push "$(echo "$TAG" | cut -d . -f 1-2)"
gh release create "$TAG" \
--title "Release $TAG" \
--notes "$CHANGELOG" \
--verify-tag
env:
USERNAME: github-actions[bot]
EMAIL: github-actions[bot]@users.noreply.github.com
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.version.outputs.new_tag }}
CHANGELOG: ${{ steps.version.outputs.changelog }}We use mathieudutour/github-tag-action to parse the next version from Conventional Commit messages. It runs in dry-run mode to generate the version without actually pushing it. If your organisation bans external actions, you'll need to implement version logic with a custom action yourself.
The release step creates three tags and force-pushes them. Yes, force-pushing is usually considered extreme, but we're moving tag pointers, not rewriting history. This is safe in the pipeline, but don’t do it on your machine. The consequence of tag mutation is that other developers need to run git pull --force to sync updated tags.
We generate a basic changelog from commits. I don't endorse maintaining CHANGELOG.md files as they're often out of sync. Instead, create a commit log during release, and edit the release notes afterwards if needed.
Post-Release Verification: Test Like a User
The release is tagged and pushed. But we're not done yet. We need one final check: verifying the action works exactly as users will use it by referencing the released tag, not local files.
jobs:
test-unit: ...
test-local-action: ...
release: ...
test-tagged-action:
name: Test tagged action
runs-on: ubuntu-latest
needs: [release]
permissions:
contents: read
steps:
- name: Test happy case
uses: nikoheikkila/rot-13-action@v1
id: valid
with:
string: ${{ env.original }}
- name: Test sad case
uses: nikoheikkila/rot-13-action@v1
id: invalid
continue-on-error: true
with:
string: "" Notice the critical difference from earlier verification: we reference the action using nikoheikkila/rot-13-action@v1, not ./. This tests exactly what users will run.
If this job is successful, you can have high confidence that your release works correctly. It's not just tagged, but also verified.
Conclusion: Actions Are Software
When this series began, you might have viewed GitHub Actions as simple automation scripts too trivial for serious software engineering practices.
I hope you now see the light: GitHub Actions are software. They deserve the same software engineering rigour as any production system: clean architecture, comprehensive testing, automated verification, and safe delivery pipelines.
The investment pays off in reliability, maintainability, and confidence. Instead of "push and pray" development, you have a fast feedback loop that catches bugs before users do. Instead of fragile scripts that break mysteriously, you have well-tested components that adapt to change.
What You've Learned
Through this series, you've mastered:
Design: Separating business logic from infrastructure using dependency injection
Testing: Unit tests, property-based tests, and mutation testing for genuine confidence
Building: Creating reproducible production artifacts
Releasing: Semantic versioning with automated verification
Delivery: Safe deployment with post-release verification
Next Steps
Clone the example repository and use it as a foundation for your own actions. The code is production-ready, battle-tested, and follows the principles we've discussed.
Found something to improve? Submit an issue or pull request. All contributions are welcome. After all, continuous improvement is what software craftsmanship is all about.
Now go build something great. Your users will thank you for the reliability, and your future self will thank you for the maintainability.