Skip to content

Commit fa7a6d6

Browse files
ci(release): derive version from release tag and temporarily rewrite package.json
Update the stable release workflow to extract the version directly from the GitHub release tag (vX.Y.Z) and temporarily rewrite package.json during CI. This decouples the release tag source of truth from the source manifest. Changes: - Add inline version resolution step that parses stable vX.Y.Z format from tag - Add temporary package.json rewrite step before verification - Move verification to validate the rewritten manifest - Update tests to reflect the new behavior and validate tag format - Update README documentation for the new release flow Co-Authored-By: Hagicode <noreply@hagicode.com>
1 parent bb64c55 commit fa7a6d6

4 files changed

Lines changed: 94 additions & 20 deletions

File tree

.github/workflows/npm-publish-dev.yml

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,44 @@ jobs:
8383
cache-dependency-path: package-lock.json
8484
registry-url: https://registry.npmjs.org
8585

86-
- name: Verify release tag matches package version
86+
- name: Resolve stable release version
8787
id: version
88+
env:
89+
RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
8890
run: |
89-
version="$(npm run --silent publish:verify-release -- "${{ github.event.release.tag_name }}")"
91+
version="$(node --input-type=module <<'EOF'
92+
const tagName = process.env.RELEASE_TAG_NAME ?? "";
93+
const match = tagName.match(/^v(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)$/);
94+
95+
if (!match?.groups) {
96+
console.error(`Release tags must use the stable vX.Y.Z format. Received: ${tagName}`);
97+
process.exit(1);
98+
}
99+
100+
process.stdout.write(`${match.groups.major}.${match.groups.minor}.${match.groups.patch}`);
101+
EOF
102+
)"
90103
echo "version=$version" >> "$GITHUB_OUTPUT"
104+
echo "Resolved stable release version: $version"
105+
106+
- name: Temporarily rewrite package.json version for stable publish
107+
env:
108+
RELEASE_VERSION: ${{ steps.version.outputs.version }}
109+
run: |
110+
node --input-type=module <<'EOF'
111+
import fs from "node:fs";
112+
113+
const packageJsonPath = "package.json";
114+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
115+
116+
packageJson.version = process.env.RELEASE_VERSION;
117+
fs.writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
118+
EOF
119+
echo "Temporarily rewrote package.json to version $RELEASE_VERSION"
120+
121+
- name: Verify release tag matches package version
122+
run: |
123+
version="$(npm run --silent publish:verify-release -- "${{ github.event.release.tag_name }}")"
91124
echo "Validated release version: $version"
92125
93126
- name: Install dependencies

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ ImgBin includes a GitHub Actions based npm publishing workflow for both prerelea
3636
- Pushes to `main` publish a unique prerelease build to the npm `dev` dist-tag.
3737
- Pushes to `main` also refresh the GitHub draft release notes through Release Drafter.
3838
- Stable releases publish only when a GitHub draft release for tag `vX.Y.Z` is published and target the npm `latest` dist-tag.
39-
- The stable release workflow fails if the release tag version does not exactly match `package.json`.
39+
- The stable release workflow derives the publish version from the GitHub Release tag, temporarily rewrites `package.json` to that version inside CI, and then verifies the rewritten manifest before publishing.
4040

4141
### Release draft flow
4242

@@ -73,17 +73,17 @@ npm run pack:check
7373

7474
For a stable release:
7575

76-
1. update `package.json` to the target stable version,
77-
2. make sure the Release Drafter draft uses the matching tag such as `v0.1.0`, and
76+
1. make sure the Release Drafter draft uses the target stable tag such as `v0.1.1`,
77+
2. optionally simulate the workflow locally by rewriting a temporary copy of `package.json` to `0.1.1` and running `node scripts/verify-release-version.mjs v0.1.1 /path/to/temp-package.json`, and
7878
3. publish that draft release from the GitHub UI.
7979

80-
The stable publish workflow checks out the published release tag and validates that it still matches `package.json` before running `npm publish --tag latest`.
80+
The stable publish workflow checks out the published release tag, resolves `0.1.1` from `v0.1.1`, temporarily rewrites `package.json` to `0.1.1`, and then validates that rewritten manifest before running `npm publish --tag latest`.
8181

8282
### Troubleshooting release drafts
8383

8484
- If the draft notes are empty or mis-categorized, check the merged PR labels against `repos/imgbin/.github/release-drafter.yml`.
8585
- If `latest` did not publish after releasing the draft, inspect `repos/imgbin/.github/workflows/npm-publish-dev.yml` for the `release.published` run.
86-
- If the workflow reports a version mismatch, compare the published release tag with `package.json` and rerun after correcting the version source of truth.
86+
- If the workflow reports a version mismatch, compare the published release tag with the temporary `package.json` rewrite step output and rerun after correcting the release tag or manifest source.
8787
- If you need to discard a pending stable release, delete the draft release in GitHub before publishing it.
8888

8989
## Usage guide

src/__tests__/release-version-script.test.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,16 @@ function writeJson(filePath: string, data: unknown) {
1313
}
1414

1515
describe('verify-release-version script', () => {
16-
it('accepts a GitHub release event payload as the tag source', () => {
16+
it('accepts a GitHub release event payload after package.json is temporarily rewritten to the tag version', () => {
1717
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'imgbin-release-version-'));
1818

1919
try {
2020
const packageJsonPath = path.join(tempDir, 'package.json');
2121
const eventPath = path.join(tempDir, 'release-event.json');
2222

2323
writeJson(packageJsonPath, { name: '@hagicode/imgbin', version: '1.2.3' });
24-
writeJson(eventPath, { release: { tag_name: 'v1.2.3' } });
24+
writeJson(eventPath, { release: { tag_name: 'v1.2.4' } });
25+
writeJson(packageJsonPath, { name: '@hagicode/imgbin', version: '1.2.4' });
2526

2627
const result = spawnSync(process.execPath, [scriptPath, '', packageJsonPath], {
2728
cwd: tempDir,
@@ -30,13 +31,13 @@ describe('verify-release-version script', () => {
3031
});
3132

3233
expect(result.status).toBe(0);
33-
expect(result.stdout).toBe('1.2.3');
34+
expect(result.stdout).toBe('1.2.4');
3435
} finally {
3536
rmSync(tempDir, { recursive: true, force: true });
3637
}
3738
});
3839

39-
it('fails when the published release tag does not match package.json', () => {
40+
it('fails when the temporary package.json rewrite does not align with the published release tag', () => {
4041
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'imgbin-release-version-'));
4142

4243
try {
@@ -58,4 +59,24 @@ describe('verify-release-version script', () => {
5859
rmSync(tempDir, { recursive: true, force: true });
5960
}
6061
});
62+
63+
it('fails when the release tag is not in the stable vX.Y.Z format', () => {
64+
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'imgbin-release-version-'));
65+
66+
try {
67+
const packageJsonPath = path.join(tempDir, 'package.json');
68+
writeJson(packageJsonPath, { name: '@hagicode/imgbin', version: '1.2.3' });
69+
70+
const result = spawnSync(process.execPath, [scriptPath, 'release-1.2.3', packageJsonPath], {
71+
cwd: tempDir,
72+
env: process.env,
73+
encoding: 'utf8'
74+
});
75+
76+
expect(result.status).not.toBe(0);
77+
expect(result.stderr).toContain('stable vX.Y.Z format');
78+
} finally {
79+
rmSync(tempDir, { recursive: true, force: true });
80+
}
81+
});
6182
});

src/__tests__/release-workflows.test.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,36 @@ describe('release workflow configuration', () => {
5050
const checkoutStep = releaseJob.steps.find((step: Record<string, unknown>) => step.name === 'Checkout repository');
5151
expect(checkoutStep.with.ref).toBe('${{ github.event.release.tag_name }}');
5252

53+
const resolveVersionStep = releaseJob.steps.find(
54+
(step: Record<string, unknown>) => step.name === 'Resolve stable release version'
55+
);
56+
expect(resolveVersionStep.id).toBe('version');
57+
expect(resolveVersionStep.env.RELEASE_TAG_NAME).toBe('${{ github.event.release.tag_name }}');
58+
expect(resolveVersionStep.run).toContain('process.env.RELEASE_TAG_NAME');
59+
expect(resolveVersionStep.run).toContain('stable vX.Y.Z format');
60+
61+
const rewriteVersionStep = releaseJob.steps.find(
62+
(step: Record<string, unknown>) => step.name === 'Temporarily rewrite package.json version for stable publish'
63+
);
64+
expect(rewriteVersionStep.env.RELEASE_VERSION).toBe('${{ steps.version.outputs.version }}');
65+
expect(rewriteVersionStep.run).toContain('packageJson.version = process.env.RELEASE_VERSION');
66+
expect(rewriteVersionStep.run).toContain('Temporarily rewrote package.json');
67+
68+
const verifyStep = releaseJob.steps.find(
69+
(step: Record<string, unknown>) => step.name === 'Verify release tag matches package version'
70+
);
71+
expect(verifyStep.run).toContain('publish:verify-release');
72+
expect(verifyStep.run).toContain('${{ github.event.release.tag_name }}');
73+
5374
const stepNames = releaseJob.steps.map((step: Record<string, unknown>) => step.name);
54-
expect(stepNames).toEqual(
55-
expect.arrayContaining([
56-
'Verify release tag matches package version',
57-
'Install dependencies',
58-
'Build package',
59-
'Run tests',
60-
'Verify packed files',
61-
'Publish to npm latest dist-tag'
62-
])
75+
expect(stepNames.indexOf('Resolve stable release version')).toBeLessThan(
76+
stepNames.indexOf('Temporarily rewrite package.json version for stable publish')
77+
);
78+
expect(stepNames.indexOf('Temporarily rewrite package.json version for stable publish')).toBeLessThan(
79+
stepNames.indexOf('Verify release tag matches package version')
80+
);
81+
expect(stepNames.indexOf('Verify release tag matches package version')).toBeLessThan(
82+
stepNames.indexOf('Install dependencies')
6383
);
6484
});
6585
});

0 commit comments

Comments
 (0)