Tag, build, push a release
A release pipeline: derive the version from package.json, create a git tag, build the artefact, then push it somewhere durable. Wires step outputs through three steps and uses one tiny action for the bit shell isn't great at (JSON parsing).
The shape
# zorb.yml
tasks:
release:
description: Cut a release from the current commit
inputs:
channel:
description: NPM dist-tag (latest | next | beta)
type: string
default: latest
steps:
- id: version
name: Read version from package.json
uses: ./scripts/version.action
with:
path: ./package.json
- name: Refuse to release a dirty tree
run: |
set -euo pipefail
if [ -n "$(git status --porcelain)" ]; then
echo "error: working tree has uncommitted changes" >&2
git status --short >&2
exit 1
fi
- id: tag
name: Create and push the git tag
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
set -euo pipefail
TAG="v$VERSION"
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "error: tag $TAG already exists" >&2
exit 1
fi
git tag -a "$TAG" -m "Release $TAG"
git push origin "$TAG"
echo "tag=$TAG" >> "$ZORB_OUTPUT"
- name: Build
env:
NODE_ENV: production
VERSION: ${{ steps.version.outputs.version }}
run: npm run build
- name: Publish to npm
env:
TAG: ${{ steps.tag.outputs.tag }}
CHANNEL: ${{ inputs.channel }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc
npm publish --tag "$CHANNEL"
echo "Published $TAG to channel $CHANNEL"The action:
// scripts/version.action
import { readFileSync } from 'node:fs';
export function action(inputs: { path?: string }, context: { log: { info(m: string): void } }) {
const path = inputs.path ?? 'package.json';
const pkg = JSON.parse(readFileSync(path, 'utf-8'));
context.log.info(`Detected version ${pkg.version}`);
return { version: pkg.version, name: pkg.name };
}Run it:
zorb run release
zorb run release --with channel=nextWiring outputs through the steps
The flow is:
version.actionreturns{ version, name }. The step'sid: versionmakes those addressable via${{ steps.version.outputs.version }}.- The tag step reads the version into
$VERSIONand writes the resulting tag back to$ZORB_OUTPUT. The lineecho "tag=v$VERSION" >> "$ZORB_OUTPUT"becomessteps.tag.outputs.tag. - The publish step picks up the tag from
${{ steps.tag.outputs.tag }}.
Every cross-step value goes through env: first. run: strings are never interpolated — see Concepts → Expressions for why.
Failing fast
Two guard rails keep the task honest:
- Dirty-tree check — refuses to release if
git status --porcelainshows anything. Cheap, prevents half-committed releases. - Tag collision check — refuses if
git rev-parse "$TAG"succeeds. Cheap, prevents accidental retags.
Both run before any side effect that touches the network. Failures exit non-zero, which fails the task — there's no need for an on-failure: clause.
Secrets, not env vars
NPM_TOKEN comes from ${{ secrets.NPM_TOKEN }}, not ${{ env.NPM_TOKEN }}. The difference matters: registered secrets are masked to *** in step output. If the publish step accidentally logs --token=$NPM_TOKEN, the token won't leak into CI logs.
Wire a secrets loader at the top of the workflow:
secrets:
- uses: '@zorb/secrets/load-1password'
with:
vault: Releases
items: [NPM_TOKEN]…or load it from a gitignored dotenv via a different loader. Either way, the workflow declares the dependency explicitly.
Retries on the publish step
npm publish is one of those steps that occasionally fails on a transient network error. Opt into retries with exponential backoff:
- name: Publish to npm
retries: 3
backoff: exponential
env:
# …
run: npm publish --tag "$CHANNEL"zorb sleeps 1s, 2s, 4s between attempts. Three additional attempts on top of the first means up to four total — if all four fail, the step fails the task.
See also
- Reading values from
package.json— theversion.actionin full, with tests. - Workflow format → Step outputs — the wire protocol behind
$ZORB_OUTPUT. - Concepts → Secrets — how
secrets.<name>is built on top of actions.