Shared release plumbing for Sellpy's npm packages. Four things live here:
semver-label— a composite action that resolves a pull request'smajor/minor/patchlabel to an npm bump type, failing unless exactly one is present. This is the single definition of that rule.require-build-hook— a composite action that fails when a package has abuildscript but no npm lifecycle hook to run it. Internal to the workflows below rather than something a caller invokes..github/workflows/npm-publish-master.yml— a reusable workflow that publishes a single-package repo from its default branch..github/workflows/npm-publish-preview.yml— a reusable workflow that publishes a throwaway preview build of a single-package repo from any other branch.
This repository is public so that consuming repositories — including private ones — can resolve the action and the reusable workflows without any org-level sharing setting to keep enabled. That is the only reason it is public: it is an internal tool, built around Sellpy's conventions, and it is unlikely to be useful outside them. No support or stability promises are made to anyone outside Sellpy, and issues or pull requests from outside are not expected.
Nothing here is a secret: the action and the workflow contain no credentials. Consumers pass
their own NPM_TOKEN in, and it stays in the calling repository's secrets.
The released version used to live in two places that had to agree — package.json on the
default branch, and the registry — with nothing enforcing that they did. The old step order
made drift likely rather than exotic: npm publish ran before git push, so a failed push
left a stranded tag and the default branch sitting on an already-published version, and
every later run tried to republish it until someone fixed the branch by hand.
The registry is the natural home for it, because that is where the irreversible step already happens. The truth and the point of no return become the same place, so they cannot disagree.
Consuming repos keep a 0.0.0-managed placeholder in package.json to make it explicit
that the field is not read.
Publishing needed a PAT only because it pushed the version bump back to a protected branch,
which the built-in GITHUB_TOKEN cannot do. Nothing is pushed to the default branch now —
only a vX.Y.Z tag, as a pointer for humans — and tags are not covered by branch
protection, so GITHUB_TOKEN is sufficient. A PAT is tied to one person and expires; when
it lapses the whole release path goes down with it.
The workflow triggers on a pull request closing rather than on a push to the default branch,
because the semver label lives on the pull request — a push event carries no labels, so there
would be nothing to read major/minor/patch from.
That trigger is what makes the override necessary. On a pull_request event, checkout does
not hand you the default branch. It hands you a merge preview GitHub computed in advance —
"this PR's branch merged into master" — calculated when the PR was last updated and never
refreshed when master moves afterwards:
- PR A is opened. GitHub computes A-merged-into-master.
- PR B merges. Master now has B.
- PR A merges, this workflow fires, and checkout returns the preview from step 1 — which has no B in it.
- The published tarball is missing B, even though master has it.
ref: ${{ github.event.repository.default_branch }} fetches the branch's actual current tip
instead. None of this applies to a push-triggered workflow, where the default checkout is
already the commit that just landed — which is why the override looks redundant until you
notice the trigger.
name: Publish master
on:
pull_request:
branches: [master]
types: [closed]
# Serialise publishes. The next version is derived from the registry, so two runs reading
# the registry at once would resolve the same version and one would fail. Never cancel: a
# cancelled run could stop between publishing and tagging.
concurrency:
group: publish-master
cancel-in-progress: false
jobs:
validate:
uses: ./.github/workflows/code-validation.yml
publish:
if: github.event.pull_request.merged == true
needs: validate
permissions:
contents: write
uses: sellpy/package-release-actions/.github/workflows/npm-publish-master.yml@v1
with:
labels: ${{ toJSON(github.event.pull_request.labels.*.name) }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}permissions: contents: write is required in the caller and is not optional. A called
workflow can lower the permissions it is handed but never raise them, so the contents: write
declared inside this workflow is a ceiling, not a grant — without it in the caller, the tag
push fails with a 403 that says nothing about permissions.
The caller owns the trigger, the concurrency group and the test gate, so each repo keeps its
own validation workflow and its own publish secret name. actions/checkout inside a
reusable workflow checks out the calling repo, which is what we want.
labels is the only input and NPM_TOKEN the only secret, which keeps the contract small
enough to hold stable across @v1. Node version (.nvmrc), runner (ubuntu-22.04) and
registry (https://registry.npmjs.org) are fixed in the workflow, because they are the same
in every repo that publishes this way — parameterising them would encode drift rather than
remove it. An optional input can be added later without a breaking change if one of them
ever genuinely needs to vary.
The workflow outputs version, the version it published.
A preview build publishes the tip of a branch as 0.0.0-<branch>-<sha> under a dist-tag named
after the branch, so npm i @sellpy/commons@canary — or @int-2497-urbify — resolves it. It
is throwaway: no tag, no release, nothing written back to the branch.
name: Publish preview build
on:
push:
branches: [dev, canary]
concurrency:
group: publish-${{ github.ref_name }}
cancel-in-progress: false
jobs:
validate:
uses: ./.github/workflows/code-validation.yml
publish:
needs: validate
uses: sellpy/package-release-actions/.github/workflows/npm-publish-preview.yml@v1
secrets:
NPM_TOKEN: ${{ secrets.NPM_AUTOMATION_TOKEN }}This contract is smaller than the master one: no inputs at all, one secret, and the same
version output. The dist-tag and the version segment both derive from github.ref_name, so
there is nothing for a caller to pass and nothing for two repos to disagree about. As with the
master workflow, the caller owns the trigger, the concurrency group and the validation gate.
No permissions block is needed, unlike the master workflow — nothing is pushed back, so the
default token is already sufficient.
Add workflow_dispatch to the trigger where publishing an arbitrary feature branch is
useful. Branch names are sanitised into the version segment and the dist-tag
(int-2497/urbify → int-2497-urbify), so any branch is safe to dispatch against; the
sanitisation is unconditional rather than an option, because a repo that does not need it is
unaffected by it.
Two of the three workflows this replaced ran npm version and then
git push --follow-tags back to the branch, which is the same pattern the master path was
changed to stop doing, for the same reasons — plus one specific to previews: npm version
trips over the tag it created on the previous run, so re-running a preview on an unchanged
commit fails.
Both flags on npm version are load-bearing. --no-git-tag-version keeps the bump inside the
runner, so there is no commit and no tag to push and the workflow needs neither a git identity
nor contents: write. --allow-same-version is what makes a re-run on an unchanged commit
succeed, since the version it computes is derived from the SHA and therefore identical to the
one already in package.json from the previous run.
One consequence to know about when porting a repo that used to push back: the version field
it last pushed is now frozen on that branch forever. Reset it to the 0.0.0-managed
placeholder, on every preview branch rather than just the one you happened to look at.
Neither workflow here builds explicitly. Both rely on npm's lifecycle hooks — prepare,
prepack or prepublishOnly — firing during npm ci and npm publish, so the build stays
defined by the repo rather than duplicated in shared CI.
That check lives in the require-build-hook action rather than inline in each workflow. It was
duplicated verbatim in every one of them, differing only in which directory's package.json it
read, which is the same shape that earned semver-label its own action: one rule, one
parameter, several callers.
The rule is deliberately narrow: a package with a build script must have a hook that runs
it. A package with no build script has nothing to build and passes. That distinction
matters — @sellpy/design-system-commons publishes source directories with no build step at
all, and a blanket "must have a hook" rule would fail it for no reason, forcing either a skip
switch on the contract or a fake prepack in the repo.
What the rule does catch is the genuinely dangerous state: a build that exists but is not wired
to publishing. automation-commons was in it and would have published an empty dist/.
fetch-graphql-schema shows how quiet the failure is — main and bin both point into lib/,
which is gitignored, so a publish without its prepack would ship a package whose entry point
and CLI do not exist. Nothing about the run would say so, which is why this is worth a step that
cannot pass by accident. It runs before npm ci so the run fails in seconds.
dev and canary are long-lived and re-cut from the default branch by hand, so a hook added
to the default branch is not present on them until someone merges it across. That is why the
check lives in the shared workflow rather than being fixed once per repo.
Both workflows install first and only then overwrite .npmrc with NPM_TOKEN. The order is
deliberate and easy to "tidy" into a bug.
npm ci runs against whatever .npmrc the calling repo commits. Where a repo has private
@sellpy/* dependencies — pdf-creator is the current case — that committed token is what can
read them, and NPM_TOKEN generally cannot: a publish token for one package carries no read
rights on another. Writing NPM_TOKEN before installing therefore breaks the install.
What makes it worth documenting rather than leaving to be rediscovered is the error you get. npm answers 404, not 403, for a private package the caller may not fetch, so the failure reads as a missing tarball or a bad version rather than a permissions problem, and the obvious next move — checking whether the dependency exists — finds nothing wrong.
The PR gate needs the same rule, without a publish:
name: PR Validation
on:
pull_request:
types: [opened, labeled, unlabeled, synchronize]
jobs:
require-labels:
name: Exactly one Semantic Version label
runs-on: ubuntu-22.04
steps:
- uses: sellpy/package-release-actions/semver-label@v1
with:
labels: ${{ toJSON(github.event.pull_request.labels.*.name) }}No actions/checkout is needed — that is only required for an action stored in the calling
repo itself.
Consumers pin @v1. The v1 tag moves as fixes land; cut v2 for a breaking change to
inputs or behaviour.
The workflows reference semver-label@v1 and require-build-hook@v1 internally, by absolute
path with the tag hardcoded, so everything here is released together — bump all of it when
cutting a new major. A ./-relative path is not an option: inside a reusable workflow it
resolves against the caller's checkout rather than this repo, and expressions are not allowed
in uses:.
The practical consequence is that a new internal action cannot be referenced until v1 includes
it. Because the workflow and the action are resolved at the same tag, moving v1 to a commit
containing both is atomic from a consumer's point of view — but cutting a release that moves
only one of them would break every caller.
- Monorepos.
sellpy/design-systempublishes three packages with independent versions and cross-package pinning. Both workflows here resolve one package from the repo root, so neither can serve it; it needs a workspace-aware variant of each. - Release-triggered packages.
sellpy/react-native-scroll-anchorpublishes on a GitHub release rather than a labelled merge.