fix(release): reject leading-zero versions and pre-check npm before publish (#219)

* fix(release): reject leading-zero versions and pre-check npm before publish

The validate-version job used ^[0-9]+\.[0-9]+\.0$ which accepted leading
zeros (e.g. 1.01.0). npm version silently normalises such inputs to their
canonical semver form (1.1.0), creating divergent state across npm, git
tags, GitHub releases, and release branches — leaving orphaned artefacts
that require manual surgery to clean up.

Two changes to the validate-version job only:

1. Replace the format regex with ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.0$
   so any segment with a leading zero is rejected in under 5 seconds with
   an error message that includes the offending value.

2. Add a "Reject already-published versions" step that calls
   `npm view $pkg@$VERSION` for both packages before any branch, install,
   or build work begins. Duplicate-version requests now fail fast instead
   of burning ~10 minutes before dying at the dry-run publish step.

Adds ADR-0175 documenting the incident, the decisions, and the recovery
runbook for the orphaned v1.01.0 / v1.03.0 artefacts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(adr): rename ADR to issue-number format per CONTRIBUTING.md policy

Renames docs/adr/0175-release-version-validation.md to
docs/adr/218-release-version-validation.md to match the issue-number
prefix convention required by CONTRIBUTING.md (section: Proposing an
ADR or PRD). Issue #218 was opened to track this CI hardening work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-24 11:21:43 -04:00
committed by GitHub
parent 4a03ada279
commit 6fc46db49a
2 changed files with 104 additions and 4 deletions

View File

@@ -47,20 +47,31 @@ jobs:
env:
VERSION: ${{ inputs.version }}
run: |
# Must be X.Y.0 (minor or major release, not patch)
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.0$'; then
echo "::error::Version must end in .0 (e.g., 1.28.0 or 2.0.0). Use hotfix workflow for patch releases."
# Must be X.Y.0 (minor or major release, not patch), no leading zeros in any segment
if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.0$'; then
echo "::error::Version '$VERSION' is invalid. Must be X.Y.0 with no leading zeros (e.g., 1.28.0 or 2.0.0). Use hotfix workflow for patch releases."
exit 1
fi
BRANCH="release/${VERSION}"
# Detect major (X.0.0)
IS_MAJOR="false"
if echo "$VERSION" | grep -qE '^[0-9]+\.0\.0$'; then
if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.0\.0$'; then
IS_MAJOR="true"
fi
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "is_major=$IS_MAJOR" >> "$GITHUB_OUTPUT"
- name: Reject already-published versions
env:
VERSION: ${{ inputs.version }}
run: |
for pkg in @opengsd/get-shit-done-redux @opengsd/gsd-sdk; do
if npm view "$pkg@$VERSION" version >/dev/null 2>&1; then
echo "::error::$pkg@$VERSION is already published on npm — bump to a new version"
exit 1
fi
done
create:
needs: validate-version
if: inputs.action == 'create'

View File

@@ -0,0 +1,89 @@
# ADR-0175: Harden release-workflow version validation — reject leading zeros and pre-check npm
- **Status:** Accepted (2026-05-24)
- **Date:** 2026-05-24
## Context
### The leading-zero normalization incident
The release workflow's `validate-version` job used the regex `^[0-9]+\.[0-9]+\.0$` to gate the `version` input. This regex accepts leading zeros in any segment (`1.01.0`, `01.0.0`, etc.) because `[0-9]+` matches one or more digits without anchoring against a leading zero.
A maintainer triggered the workflow with `version=1.01.0`. The validator accepted it. Downstream steps called `npm version 1.01.0`, which silently normalized the string to `1.1.0` per the semver specification (leading zeros are stripped). From that point the pipeline had irreconcilably divergent state:
| Artifact | Value |
|---|---|
| npm registry (`@opengsd/get-shit-done-redux`, `@opengsd/gsd-sdk`) | `1.1.0` — published, immutable |
| git tag | `v1.01.0` |
| GitHub release title | `v1.1.0` |
| GitHub release tag | `v1.01.0` |
| Release branch | `release/1.01.0` |
The workflow run ultimately failed downstream. A subsequent attempt to re-run finalize for `version=1.1.0` failed at the `Dry-run publish validation` step because npm refuses to republish an already-published version.
A second typo (`version=1.03.0`) produced a similar orphan tag and branch but never published to npm.
### The duplicate-version late-failure hole
The validator only checked format. It did not check whether the requested version was already live on npm. A duplicate-version request (e.g. re-running `finalize` for an already-published version, or mistyping a version that normalizes to one already published) ran through the full checkout → install → build → test cycle — roughly 10 minutes — before failing at the `Dry-run publish validation` step.
## Decision
### 1. Tighten the format regex to forbid leading zeros
Replace `^[0-9]+\.[0-9]+\.0$` with `^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.0$`.
Each segment now matches either `0` exactly or a string starting with a non-zero digit followed by zero or more digits. `1.01.0` fails because the minor segment `01` matches neither alternative. The error message includes the offending value so maintainers can correct it immediately.
The `IS_MAJOR` detection regex is updated in parallel: `^[0-9]+\.0\.0$` → `^(0|[1-9][0-9]*)\.0\.0$`.
### 2. Add a duplicate-version precheck against npm at validation time
A new `Reject already-published versions` step runs immediately after format validation in the `validate-version` job. It calls `npm view "$pkg@$VERSION" version` for both `@opengsd/get-shit-done-redux` and `@opengsd/gsd-sdk`. If either resolves, the job fails in under 5 seconds with a clear error. No build or test cycle is wasted.
## Recovery sequence
The following steps recover the production state left by the `1.01.0` and `1.03.0` incidents. Include them here so future maintainers do not need to re-derive them.
### 1. Cancel any failing in-flight runs
```sh
gh run cancel <run-id> --repo open-gsd/get-shit-done-redux
```
### 2. Fix the v1.1.0 divergence (npm published, tag/release/branch on wrong name)
```sh
# Fetch the release branch that holds the correct package.json at 1.1.0
git fetch origin release/1.1.0
# Create a proper v1.1.0 tag at the head of release/1.1.0
git tag v1.1.0 <sha-of-release/1.1.0-head>
git push origin v1.1.0
# Retarget the existing GitHub release from the typo tag to the correct one
gh release edit v1.01.0 --repo open-gsd/get-shit-done-redux --tag v1.1.0
gh release edit v1.1.0 --repo open-gsd/get-shit-done-redux --verify-tag
# Delete the typo tag and branch
git push origin :refs/tags/v1.01.0
git push origin --delete release/1.01.0
```
### 3. Clean the unpublished v1.03.0 orphan
```sh
git push origin :refs/tags/v1.03.0
git push origin --delete release/1.03.0
```
### 4. Resume releases at 1.2.0
`1.1.0` is consumed on npm and the workflow only allows `.0` patch-component versions. The next valid release is `1.2.0`.
## Consequences
- **Valid inputs are unaffected.** The new regex accepts every version that the old regex accepted minus leading-zero forms. All existing release runs used well-formed versions; no regression for normal use.
- **Leading-zero inputs fail in under 5 seconds** at the `validate-version` job before any branch, install, or publish operation runs.
- **Duplicate-version inputs fail in under 5 seconds** at `validate-version` rather than after a full install-and-test cycle.
- **The late dry-run check in `finalize` is unchanged.** It remains a belt-and-suspenders guard; the new precheck does not remove it.