1. The YAML Gray Box
2. The Solution: Testable Infrastructure as Code
Mockable and Reliable
Semantic Safety Pre-checks: Defense in Depth
What the Hook looks like
4. Enforcing the Culture: The "Welcome" Bridge
3. Bonus Brick: Frictionless Local Delivery
The Edge Cases (What's Next?)
Conclusion Photo by Andy Hermawan on Unsplash Pro Tip: You can easily extend this article's logic to other security checks. Like ensuring source maps are stripped from production deliverables. π€ͺ
If you think I am targeting a specific AI company here, I honestly don't have any claw about what's on your mind. π If you work in a modern software development company, you probably noticed a wall between "Devs" and "DevOps." For a client I will not mention for obvious reasons, there was a strong "not my job" paradigm that made Release Versioning a "burning potato" everyone was passing to the other. Additionally, the actual incrementing of version numbers, tagging, and branching is often delegated to a DevOps pipelineβa black box of huge YAML files and obscure bash scripts that went against everything we learned from the development world: "testing is king." Frequent back-and-forth discussions to decide if a change was a major, minor, or fix iteration were common, slowing us down from achieving our weekly delivery goals. To sum it up, developers have every reason to take this "heavy lifting" onto themselves, occupying a strategic position between Dev and DevOps: a "DevDevOps." What if our release pipelines were testable, mockable Infrastructure-as-Code written in the language the developers already know? In this article, I show you how to build a testable, fully automatic release architecture that uses package.json as the trigger and single source of truth for creating deliverables, tagging git versions, and committing changes to a version branch. You can find a template project to start from on the versioning-as-code github repository My initial attempt at automating version bumps looked like most enterprise setups: a complex GitHub Action. We embedded shell commands directly into the YAML to read the last commit, parse the package.json, compare it to the current commit, and execute Git tags if they differed. The Problem: You cannot unit test a shell pipe inside a YAML file. Testing required committing, pushing to a remote branch, waiting 3 minutes for a runner, and praying it worked. It was brittle, untestable, and completely opaque to the development team. I realized that "logic shouldn't live in YAML." Extracting the entire versioning logic into clean, isolated TypeScript files (check-version.ts and tag-and-branch.ts) transformed it into regular code that we can test in a few milliseconds. This allowed us to iterate quickly and add constraints, such as verifying the version string format or preventing commits if the format failed a Regex test. To make the code testable without triggering actual file system reads or Git commands, I wrapped the native interactions in an exported versionHelpers object: By structuring it this way, we immediately gained the ability to write pure unit tests using our standard test runner (Vitest). We could mock the "Git history" and assert that the script correctly parsed versions without ever touching a real repository: With the logic rigorously unit-tested locally, our DevOps YAML file is reduced to its absolute minimumβa few lines of orchestration that just trigger our tested engine: Because we are using TypeScript, we can easily go beyond simple string comparison. We implemented a Semantic Comparison logic that detects and forbids "Negative Version Bumps" (downgrades). But a check is only as good as its enforcement. We use a "Defense in Depth" strategy: By combining these two distinct toolsβLocal Hooks for immediate developer feedback and GitHub Actions as a final safety auditβwe obtain a fully safe, "fail-early" system where the versioning logic is managed entirely as code. The beauty of this system is its simplicity. Here is a snippet of our .git/hooks/pre-push script: A technical hurdle is only solved if it's adopted. To ensure every developer "sticks to the plan," we removed the friction of manually configuring Git. We provide a simple scripts/setup-hooks.sh that automates the installation of the local safety valves. We then link this directly in our docs/welcome-developer.md guide. Newcomers are greeted with a clear "Run this immediately" instruction. By making the setup part of the onboarding ritual, we ensure the "Defense in Depth" isn't just an architect's dream, but a shared reality for the whole team. By combining the local hook with the cloud auditor, we ensure that the Git history remains a pristine, 1-to-1 mirror of our package.json intent. Because we shifted the "historian" duties (tagging and branching) to the cloud based entirely on Git pushes, we unlocked a massive secondary benefit: Frictionless Offline Builds. For consultants or teams working with air-gapped enterprise clients, you often need to deliver heavy Docker images via physical media (like a USB stick). Because your local package.json is already validated and bumped, you don't have to wait for the cloud CI to build a 2GB Docker artifact just so you can download it. You simply push your bump (to let the cloud do the tagging), and immediately run a local ./bundle.sh script that builds your Docker image, validates it via multi-stage testing, and ZIPs it up for handover. While this system gives developers immense control and guarantees a consistent Git history, it does not currently orchestrate remote merge conflicts on the release branch. If two developers simultaneously push a version bump (e.g., both bump to 1.1.2) to the same release branch, the last push will be rejected by Git. Currently, this requires standard Git hygiene: the second developer must run a local git pull --rebase to resolve the conflict before pushing. By treating our release pipeline as actual, testable software, we removed the friction of version bumping. Developers manage the version in the file they stare at every day (package.json), and the infrastructure dutifully, safely, and predictably documents that history in Git. That is versioning like a pro. Templates let you quickly answer FAQs or store snippets for re-use. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse
# The "Push and Pray" Anti-Pattern
run: | NEW_VERSION=$(node -p "require('./package.json').version") OLD_VERSION=$(-weight: 500;">git show HEAD~1:package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") # ... 20 more lines of bash
# The "Push and Pray" Anti-Pattern
run: | NEW_VERSION=$(node -p "require('./package.json').version") OLD_VERSION=$(-weight: 500;">git show HEAD~1:package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") # ... 20 more lines of bash
# The "Push and Pray" Anti-Pattern
run: | NEW_VERSION=$(node -p "require('./package.json').version") OLD_VERSION=$(-weight: 500;">git show HEAD~1:package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).version") # ... 20 more lines of bash
// scripts/release/check-version.ts
import { execSync } from 'child_process';
import fs from 'fs'; const SEMVER_REGEX = /^\d+\.\d+\.\d+$/; export const versionHelpers = { getCurrentVersion: () => JSON.parse(fs.readFileSync('./package.json', 'utf8')).version, getPreviousVersion: () => JSON.parse(execSync('-weight: 500;">git show HEAD~1:package.json', { encoding: 'utf8' })).version
}; export function checkVersion() { const newV = versionHelpers.getCurrentVersion(); const oldV = versionHelpers.getPreviousVersion(); // Logic: Catch "Negative Bumps" and bad formatting const comparison = compareSemver(newV, oldV); if (comparison === 1) { // New is greater return { bumped: true, version: newV }; } return { bumped: false };
}
// scripts/release/check-version.ts
import { execSync } from 'child_process';
import fs from 'fs'; const SEMVER_REGEX = /^\d+\.\d+\.\d+$/; export const versionHelpers = { getCurrentVersion: () => JSON.parse(fs.readFileSync('./package.json', 'utf8')).version, getPreviousVersion: () => JSON.parse(execSync('-weight: 500;">git show HEAD~1:package.json', { encoding: 'utf8' })).version
}; export function checkVersion() { const newV = versionHelpers.getCurrentVersion(); const oldV = versionHelpers.getPreviousVersion(); // Logic: Catch "Negative Bumps" and bad formatting const comparison = compareSemver(newV, oldV); if (comparison === 1) { // New is greater return { bumped: true, version: newV }; } return { bumped: false };
}
// scripts/release/check-version.ts
import { execSync } from 'child_process';
import fs from 'fs'; const SEMVER_REGEX = /^\d+\.\d+\.\d+$/; export const versionHelpers = { getCurrentVersion: () => JSON.parse(fs.readFileSync('./package.json', 'utf8')).version, getPreviousVersion: () => JSON.parse(execSync('-weight: 500;">git show HEAD~1:package.json', { encoding: 'utf8' })).version
}; export function checkVersion() { const newV = versionHelpers.getCurrentVersion(); const oldV = versionHelpers.getPreviousVersion(); // Logic: Catch "Negative Bumps" and bad formatting const comparison = compareSemver(newV, oldV); if (comparison === 1) { // New is greater return { bumped: true, version: newV }; } return { bumped: false };
}
// scripts/release/check-version.test.ts
import { describe, it, expect, vi } from 'vitest';
import { checkVersion, versionHelpers } from './check-version'; describe('Release Logic', () => { it('detects a version bump correctly', () => { // Safe, targeted mocks of internal methods vi.spyOn(versionHelpers, 'getCurrentVersion').mockReturnValue('1.1.0'); vi.spyOn(versionHelpers, 'getPreviousVersion').mockReturnValue('1.0.0'); const result = checkVersion(); expect(result.bumped).toBe(true); expect(result.version).toBe('1.1.0'); });
});
// scripts/release/check-version.test.ts
import { describe, it, expect, vi } from 'vitest';
import { checkVersion, versionHelpers } from './check-version'; describe('Release Logic', () => { it('detects a version bump correctly', () => { // Safe, targeted mocks of internal methods vi.spyOn(versionHelpers, 'getCurrentVersion').mockReturnValue('1.1.0'); vi.spyOn(versionHelpers, 'getPreviousVersion').mockReturnValue('1.0.0'); const result = checkVersion(); expect(result.bumped).toBe(true); expect(result.version).toBe('1.1.0'); });
});
// scripts/release/check-version.test.ts
import { describe, it, expect, vi } from 'vitest';
import { checkVersion, versionHelpers } from './check-version'; describe('Release Logic', () => { it('detects a version bump correctly', () => { // Safe, targeted mocks of internal methods vi.spyOn(versionHelpers, 'getCurrentVersion').mockReturnValue('1.1.0'); vi.spyOn(versionHelpers, 'getPreviousVersion').mockReturnValue('1.0.0'); const result = checkVersion(); expect(result.bumped).toBe(true); expect(result.version).toBe('1.1.0'); });
});
# .github/workflows/tag-on-bump.yml
jobs: tag-and-branch: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 2 } - name: Check version bump id: check run: npx tsx scripts/release/check-version.ts - name: Tag and Branch if: steps.check.outputs.bumped == 'true' run: npx tsx scripts/release/tag-and-branch.ts --run \ --version "${{ steps.check.outputs.version }}" \ --majorMinor "${{ steps.check.outputs.major_minor }}"
# .github/workflows/tag-on-bump.yml
jobs: tag-and-branch: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 2 } - name: Check version bump id: check run: npx tsx scripts/release/check-version.ts - name: Tag and Branch if: steps.check.outputs.bumped == 'true' run: npx tsx scripts/release/tag-and-branch.ts --run \ --version "${{ steps.check.outputs.version }}" \ --majorMinor "${{ steps.check.outputs.major_minor }}"
# .github/workflows/tag-on-bump.yml
jobs: tag-and-branch: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: { fetch-depth: 2 } - name: Check version bump id: check run: npx tsx scripts/release/check-version.ts - name: Tag and Branch if: steps.check.outputs.bumped == 'true' run: npx tsx scripts/release/tag-and-branch.ts --run \ --version "${{ steps.check.outputs.version }}" \ --majorMinor "${{ steps.check.outputs.major_minor }}"
#!/bin/bash
# .-weight: 500;">git/hooks/pre-push echo "π Running pre-push validation..." # 1. Ensure the code is stable
-weight: 500;">npm run test:run if [ $? -ne 0 ]; then echo "β Local tests failed! Push aborted." exit 1
fi # 2. Run the "Versioning as Code" validation logic
npx tsx scripts/release/check-version.ts if [ $? -ne 0 ]; then echo "β Version validation failed! Push aborted." exit 1
fi echo "β
All checks passed. Proceeding with push."
exit 0
#!/bin/bash
# .-weight: 500;">git/hooks/pre-push echo "π Running pre-push validation..." # 1. Ensure the code is stable
-weight: 500;">npm run test:run if [ $? -ne 0 ]; then echo "β Local tests failed! Push aborted." exit 1
fi # 2. Run the "Versioning as Code" validation logic
npx tsx scripts/release/check-version.ts if [ $? -ne 0 ]; then echo "β Version validation failed! Push aborted." exit 1
fi echo "β
All checks passed. Proceeding with push."
exit 0
#!/bin/bash
# .-weight: 500;">git/hooks/pre-push echo "π Running pre-push validation..." # 1. Ensure the code is stable
-weight: 500;">npm run test:run if [ $? -ne 0 ]; then echo "β Local tests failed! Push aborted." exit 1
fi # 2. Run the "Versioning as Code" validation logic
npx tsx scripts/release/check-version.ts if [ $? -ne 0 ]; then echo "β Version validation failed! Push aborted." exit 1
fi echo "β
All checks passed. Proceeding with push."
exit 0 - The Edge (Local Git Hook): We use a Git pre-push hook. This is a built-in Git mechanic that triggers a script on the developer's machine before the push happens. If the developer tries to push a negative bump, the script fails locally, and Git literally blocks the push. The "Safety Valve" is at the developer's fingertips.
- The Auditor (GitHub Actions): Even if a developer bypasses the hook (using --no-verify), the GitHub Action runs the exact same script on the server. If it fails there, the release pipeline (tagging and branching) is aborted, and the sabotage is logged.