diff --git a/CHANGELOG.md b/CHANGELOG.md index f9d479a..d952066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- The `bump-package-json` option (default: `false`) sets the `version` field of the root `package.json` to the release version. + ## [1.1.0] - 2026-08-09 ### Added diff --git a/README.adoc b/README.adoc index 7995f51..f24a72f 100644 --- a/README.adoc +++ b/README.adoc @@ -117,8 +117,9 @@ jobs: |=== | Input | Default | Description -| `version-file` | `''` | Path to a file to overwrite with the version. Leave empty to skip. -| `keep-v-prefix` | `false` | When `true`, writes the tag verbatim. Otherwise strips the leading `v`. +| `version-file` | `''` | Path to a file to overwrite with the version. Leave empty to skip. Not for JSON manifests: the file is replaced wholesale, so use `bump-package-json` for those. +| `keep-v-prefix` | `false` | When `true`, writes the tag verbatim. Otherwise strips the leading `v`. Does not apply to `bump-package-json`. +| `bump-package-json` | `false` | When `true`, sets the `version` field of the root `package.json`, leaving the rest of the manifest byte-for-byte intact. The leading `v` is always stripped, as npm requires plain semver. |=== === Changelog @@ -156,7 +157,7 @@ jobs: |=== | Input | Default | Description -| `commit` | `false` | Commit the modified `version-file` and `changelog-file` back to a branch. +| `commit` | `false` | Commit the modified `version-file`, `changelog-file` and `package.json` back to a branch. | `commit-message` | `+chore: release {tag}+` | `+{tag}+` is substituted with the tag. | `commit-user-name` | `github-actions` | Name used for the auto-commit author. | `commit-user-email` | `+github-actions[bot]@users.noreply.github.com+` | Email used for the auto-commit author. diff --git a/action.yml b/action.yml index b2250d7..bfe97de 100644 --- a/action.yml +++ b/action.yml @@ -45,7 +45,20 @@ inputs: keep-v-prefix: description: > When `true`, the tag is written to `version-file` verbatim. When `false` - (default), the leading `v` is stripped. + (default), the leading `v` is stripped. Does not affect + `bump-package-json`, whose version must remain plain semver. + required: false + default: 'false' + + bump-package-json: + description: > + When `true`, sets the `version` field of `package.json` in the repository + root to the release version, leaving the rest of the manifest untouched. + Use this instead of `version-file` for npm-shaped projects: `version-file` + overwrites its target with the bare version string, which would destroy a + JSON manifest. The leading `v` is always stripped, since npm requires + plain semver. The manifest is included in the commit when `commit` is + `true`. required: false default: 'false' @@ -101,8 +114,8 @@ inputs: # Commit updates back commit: description: > - When `true`, commits changes to `version-file` and `changelog-file` back - to the repository. + When `true`, commits changes to `version-file`, `changelog-file` and, when + `bump-package-json` is enabled, `package.json` back to the repository. required: false default: 'false' @@ -314,6 +327,15 @@ runs: args+=("$VERSION_FILE" "$TAG") "${GITHUB_ACTION_PATH}/scripts/bump-version.sh" "${args[@]}" + - name: Bump package.json + if: inputs.bump-package-json == 'true' + shell: bash + env: + TAG: ${{ steps.compute.outputs.tag }} + run: | + set -euo pipefail + "${GITHUB_ACTION_PATH}/scripts/bump-package-json.sh" package.json "$TAG" + - name: Update changelog if: inputs.changelog-file != '' && inputs.update-changelog != 'false' uses: thomaseizinger/keep-a-changelog-new-release@v2 @@ -336,7 +358,9 @@ runs: commit_user_name: ${{ inputs.commit-user-name }} commit_user_email: ${{ inputs.commit-user-email }} file_pattern: >- - ${{ inputs.version-file }} ${{ inputs.changelog-file }} + ${{ inputs.version-file }} + ${{ inputs.bump-package-json == 'true' && 'package.json' || '' }} + ${{ inputs.changelog-file }} branch: ${{ inputs.commit-branch }} - name: Move devbuild tag diff --git a/scripts/bump-package-json.sh b/scripts/bump-package-json.sh new file mode 100755 index 0000000..7158e03 --- /dev/null +++ b/scripts/bump-package-json.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Sets the `version` field of a package.json-style JSON manifest. +# +# Usage: bump-package-json.sh +# +# The leading `v` is always stripped, unlike `bump-version.sh`: npm requires the +# version field to be plain semver, so a `v`-prefixed value would be invalid. +# +# Only the version is rewritten. A manifest formatted the usual way keeps every +# other byte, so the resulting commit touches a single line. A manifest whose +# version cannot be located that way (unusual formatting, or no version field at +# all) is re-serialized instead, which may reformat it. + +set -euo pipefail + +file="${1:-}" +tag="${2:-}" + +if [ -z "$file" ]; then + echo "bump-package-json.sh: missing manifest path" >&2 + exit 2 +fi + +if [ -z "$tag" ]; then + echo "bump-package-json.sh: missing tag" >&2 + exit 2 +fi + +if [ ! -f "$file" ]; then + echo "bump-package-json.sh: manifest '$file' does not exist" >&2 + exit 2 +fi + +if ! command -v node >/dev/null 2>&1; then + echo "bump-package-json.sh: node is required to edit '$file'" >&2 + exit 2 +fi + +node -e ' +const fs = require("fs"); +const [file, tag] = process.argv.slice(1); +const version = tag.replace(/^v/, ""); +const source = fs.readFileSync(file, "utf8"); + +try { + JSON.parse(source); +} catch (error) { + console.error(`bump-package-json.sh: ${file} is not valid JSON: ${error.message}`); + process.exit(2); +} + +// Indentation of the first nested key, reused so the manifest keeps its shape. +const indent = (source.match(/\n([ \t]+)"/) || [])[1] || " "; + +// Anchoring to that indentation keeps the match at the top level, so a nested +// "version" (in an engines block, say) is never mistaken for the manifest own. +const topLevelVersion = new RegExp(`^${indent}"version"(\\s*:\\s*)"[^"]*"`, "m"); + +let updated; +if (topLevelVersion.test(source)) { + updated = source.replace(topLevelVersion, `${indent}"version"$1${JSON.stringify(version)}`); +} else { + const manifest = JSON.parse(source); + manifest.version = version; + const trailingNewline = source.endsWith("\n") ? "\n" : ""; + updated = JSON.stringify(manifest, null, indent) + trailingNewline; +} + +fs.writeFileSync(file, updated); +' "$file" "$tag" diff --git a/tests/bump-package-json.bats b/tests/bump-package-json.bats new file mode 100644 index 0000000..750eabd --- /dev/null +++ b/tests/bump-package-json.bats @@ -0,0 +1,121 @@ +#!/usr/bin/env bats + +load 'test_helper' + +setup() { + setup_scripts_dir + setup_tmp_workspace + MANIFEST="$TMP_WORKSPACE/package.json" +} + +teardown() { + teardown_tmp_workspace +} + +# Writes a conventional two-space manifest to $MANIFEST. +write_manifest() { + cat > "$MANIFEST" << 'JSON' +{ + "name": "example", + "version": "0.1.0", + "scripts": { + "build": "tsc" + } +} +JSON +} + +@test "strips leading v and sets the version" { + write_manifest + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.2.3" + [ "$status" -eq 0 ] + [ "$(node -p "require('$MANIFEST').version")" = "1.2.3" ] +} + +@test "leaves a tag without v prefix untouched" { + write_manifest + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "2.0.0" + [ "$status" -eq 0 ] + [ "$(node -p "require('$MANIFEST').version")" = "2.0.0" ] +} + +@test "handles semver with pre-release suffix" { + write_manifest + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.0.0-rc.1" + [ "$status" -eq 0 ] + [ "$(node -p "require('$MANIFEST').version")" = "1.0.0-rc.1" ] +} + +@test "changes only the version line" { + write_manifest + cp "$MANIFEST" "$TMP_WORKSPACE/before.json" + + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.2.3" + [ "$status" -eq 0 ] + + run diff "$TMP_WORKSPACE/before.json" "$MANIFEST" + # One changed line: a single `<` and a single `>` in the diff output. + [ "$(printf '%s\n' "$output" | grep -c '^<')" -eq 1 ] + [ "$(printf '%s\n' "$output" | grep -c '^>')" -eq 1 ] + printf '%s\n' "$output" | grep -q '"version": "1.2.3"' +} + +@test "preserves other fields and key order" { + write_manifest + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.2.3" + [ "$status" -eq 0 ] + [ "$(node -p "Object.keys(require('$MANIFEST')).join(',')")" = "name,version,scripts" ] + [ "$(node -p "require('$MANIFEST').scripts.build")" = "tsc" ] +} + +@test "ignores a nested version field" { + cat > "$MANIFEST" << 'JSON' +{ + "engines": { + "version": "18" + }, + "version": "0.1.0" +} +JSON + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.2.3" + [ "$status" -eq 0 ] + [ "$(node -p "require('$MANIFEST').engines.version")" = "18" ] + [ "$(node -p "require('$MANIFEST').version")" = "1.2.3" ] +} + +@test "adds a version field when the manifest has none" { + printf '{\n "name": "example"\n}\n' > "$MANIFEST" + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.2.3" + [ "$status" -eq 0 ] + [ "$(node -p "require('$MANIFEST').version")" = "1.2.3" ] + [ "$(node -p "require('$MANIFEST').name")" = "example" ] +} + +@test "preserves tab indentation" { + printf '{\n\t"name": "example",\n\t"version": "0.1.0"\n}\n' > "$MANIFEST" + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.2.3" + [ "$status" -eq 0 ] + grep -q "$(printf '\t"version": "1.2.3"')" "$MANIFEST" +} + +@test "fails when manifest path is empty" { + run "$SCRIPTS_DIR/bump-package-json.sh" "" "v1.0.0" + [ "$status" -ne 0 ] +} + +@test "fails when tag is empty" { + write_manifest + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "" + [ "$status" -ne 0 ] +} + +@test "fails when the manifest does not exist" { + run "$SCRIPTS_DIR/bump-package-json.sh" "$TMP_WORKSPACE/missing.json" "v1.0.0" + [ "$status" -ne 0 ] +} + +@test "fails when the manifest is not valid JSON" { + printf 'not json' > "$MANIFEST" + run "$SCRIPTS_DIR/bump-package-json.sh" "$MANIFEST" "v1.0.0" + [ "$status" -ne 0 ] +}