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:
19
.github/workflows/release.yml
vendored
19
.github/workflows/release.yml
vendored
@@ -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'
|
||||
|
||||
89
docs/adr/218-release-version-validation.md
Normal file
89
docs/adr/218-release-version-validation.md
Normal 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.
|
||||
Reference in New Issue
Block a user