diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6be91ae..98de176 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,6 +47,6 @@ jobs: with: fetch-depth: 0 - uses: denoland/setup-deno@v2 - - run: "deno run --allow-net --allow-env --allow-run shipit.ts ${{ github.ref_name != 'main' && '--dry-run' || '' }}" + - run: "deno run --allow-net --allow-env --allow-run --allow-write shipit.ts ${{ github.ref_name != 'main' && '--dry-run' || '' }}" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 498084e..7413fa4 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ these are the most notable differences from upstream: example.) - [x] Unit tests for the more complex commit messages. - [x] Update dependencies. -- [ ] TODO: Bump version number in any `deno.json` and `deno.jsonc`. +- [x] Write version number to any `deno.json` and `deno.jsonc`. - [x] Dry-run mode, doesn't make any changes, but prints new version and release notes. - Use `--dry-run` or `-n` to enable, or set `DRY_RUN=1` in your environment. @@ -56,7 +56,7 @@ these are the most notable differences from upstream: ## Usage ```sh -GITHUB_TOKEN="$(gh auth token)" deno run --allow-env --allow-run --allow-net https://raw.githubusercontent.com/hugojosefson/shipit/refs/heads/main/shipit.ts +GITHUB_TOKEN="$(gh auth token)" deno run --allow-env --allow-run --allow-net --allow-write https://raw.githubusercontent.com/hugojosefson/shipit/refs/heads/main/shipit.ts ``` If you'd prefer to run `@hugojosefson/shipit` on CI, you can: @@ -77,7 +77,7 @@ on: - uses: denoland/setup-deno@v2 # Use latest version of @hugojosefson/shipit. You may prefer to pin a specific version. - - run: deno run --allow-net --allow-env --allow-run https://raw.githubusercontent.com/hugojosefson/shipit/refs/heads/main/shipit.ts + - run: deno run --allow-env --allow-run --allow-net --allow-write https://raw.githubusercontent.com/hugojosefson/shipit/refs/heads/main/shipit.ts env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ``` @@ -88,8 +88,8 @@ step. ## Granting permissions -`@hugojosefson/shipit` requires environment, network, and subprocess creation -permissions: +`@hugojosefson/shipit` requires environment, network, subprocess creation, and +write permissions: - `--allow-env`: It reads `GITHUB_TOKEN` from your local environment in order to authenticate with GitHub. It can also read `DRY_RUN` and `VERBOSE`. @@ -97,6 +97,7 @@ permissions: gather information about your commits. - `--allow-net`: It needs to make outbound network requests to GitHub in order to create GitHub releases. +- `--allow-write`: It needs to write to any `deno.json` and `deno.jsonc` files. ## Examples diff --git a/deno.jsonc b/deno.jsonc index d777561..eff92ea 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -12,18 +12,20 @@ "test": " deno test --coverage --trace-leaks --allow-run --allow-env=VERBOSE", "test-watch": " deno test --watch --trace-leaks --allow-run --allow-env=VERBOSE", "coverage": " deno coverage", - "release": " deno task all && GITHUB_TOKEN=$(gh auth token) deno run --allow-env=GITHUB_TOKEN,VERBOSE,DRY_RUN --allow-net=api.github.com --allow-run=bash,git shipit.ts", + "release": " deno task all && GITHUB_TOKEN=$(gh auth token) deno run --allow-env=GITHUB_TOKEN,VERBOSE,DRY_RUN --allow-net=api.github.com --allow-run=bash,git --allow-write=deno.json,deno.jsonc shipit.ts", "bump-deps": " deno task forall-files-no-yaml -- deno run --allow-env --allow-read=.,$HOME/.cache/deno,$HOME/.local/share/deno-wasmbuild --allow-write=.,$HOME/.local/share/deno-wasmbuild --allow-run=git --allow-net jsr:@molt/cli@0.19.8 --commit --prefix=\"chore: \"", "list-files": " git ls-files | deno eval 'import{toText}from\"jsr:@std/streams@1.0.8\";console.log((await toText(Deno.stdin.readable)).split(\"\\n\").filter(f=>f.startsWith(\".github/workflows\")||/\\.((mj|j|t)sx?|jsonc?)$/.test(f)).filter(f=>{try{return !Deno.statSync(f).isDirectory}catch{}}).join(\"\\n\"))'", "foreach-file-no-json-yaml": "deno task list-files | grep -viE '\\.(jsonc?|ya?ml)$' | sh -c 'xargs -I {} -- \"$@\"'", "forall-files-no-yaml": " deno task list-files | grep -viE '\\.ya?ml$' | sh -c 'xargs -d \"\\n\" -- \"$@\"'" }, "imports": { + "@hugojosefson/fns": "jsr:@hugojosefson/fns@^2.1.0", "@hugojosefson/run-simple": "jsr:@hugojosefson/run-simple@^2.3.8", "@octokit/request": "npm:@octokit/request@^9.1.3", "@std/assert": "jsr:@std/assert@^1.0.8", "@std/fmt": "jsr:@std/fmt@^1.0.3", "@std/semver": "jsr:@std/semver@^1.0.3", + "jsonc-parser": "npm:jsonc-parser@^3.3.1", "parse-repo": "npm:parse-repo@^1.0.4" } } diff --git a/deno.lock b/deno.lock index 2aaf185..e5c2ff8 100644 --- a/deno.lock +++ b/deno.lock @@ -1,6 +1,7 @@ { "version": "4", "specifiers": { + "jsr:@hugojosefson/fns@^2.1.0": "2.1.0", "jsr:@hugojosefson/run-simple@^2.3.8": "2.3.8", "jsr:@std/assert@^1.0.8": "1.0.8", "jsr:@std/bytes@^1.0.3": "1.0.4", @@ -9,9 +10,17 @@ "jsr:@std/semver@^1.0.3": "1.0.3", "jsr:@std/streams@1.0.8": "1.0.8", "npm:@octokit/request@^9.1.3": "9.1.3", - "npm:parse-repo@^1.0.4": "1.0.4" + "npm:jsonc-parser@^3.3.1": "3.3.1", + "npm:parse-repo@^1.0.4": "1.0.4", + "npm:regex-merge@2": "2.0.0" }, "jsr": { + "@hugojosefson/fns@2.1.0": { + "integrity": "5d15e52a02a47286fa483a330fb9f505643c9f084ab08b92d51ff343c5500e00", + "dependencies": [ + "npm:regex-merge" + ] + }, "@hugojosefson/run-simple@2.3.8": { "integrity": "8dc0318207bd0f4e9ed46e36fb179007169f7f25b14ae251f0880e10b347020f" }, @@ -72,20 +81,28 @@ "@octokit/openapi-types" ] }, + "jsonc-parser@3.3.1": { + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==" + }, "parse-repo@1.0.4": { "integrity": "sha512-RdwYLh7cmxByP/BfeZX0QfIVfeNrH2fWgK1aLsGK+G6nCO4WTlCks4J7aW0O3Ap9BCPDF/e8rGTT50giQr10zg==" }, + "regex-merge@2.0.0": { + "integrity": "sha512-Jtv8gq8ciCfCUcwVhsBC/YrYPs4oF3wN1kZSsO3NOQbowzPa/x+8dXZ2gyvvxSGrdI9GjGSKhiEblZ9KyVTa3w==" + }, "universal-user-agent@7.0.2": { "integrity": "sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==" } }, "workspace": { "dependencies": [ + "jsr:@hugojosefson/fns@^2.1.0", "jsr:@hugojosefson/run-simple@^2.3.8", "jsr:@std/assert@^1.0.8", "jsr:@std/fmt@^1.0.3", "jsr:@std/semver@^1.0.3", "npm:@octokit/request@^9.1.3", + "npm:jsonc-parser@^3.3.1", "npm:parse-repo@^1.0.4" ] } diff --git a/json.ts b/json.ts new file mode 100644 index 0000000..f2a4ad4 --- /dev/null +++ b/json.ts @@ -0,0 +1,125 @@ +import { + applyEdits, + findNodeAtLocation, + getNodeValue, + type JSONPath, + type ModificationOptions, + modify, + type Node, + parseTree, +} from "jsonc-parser"; + +const MODIFICATION_OPTIONS: ModificationOptions = { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + }, +}; + +/** + * Set the value at the given JSON path in the given JSON or JSONC string. + * @param jsonc JSON or JSONC string to modify. + * @param jsonPath JSON path to the value to set. + * @param value Value to set. + * @returns New JSON or JSONC string with the value set. + */ +export function setValueInJson( + jsonc: string, + jsonPath: JSONPath, + value: V, +): string { + const edits = modify(jsonc, jsonPath, value, MODIFICATION_OPTIONS); + return applyEdits(jsonc, edits); +} + +/** + * Set the value at the given JSON path in the given JSON or JSONC file. + * @param filePath Path to the JSON or JSONC file to modify. + * @param jsonPath JSON path to the value to set. + * @param value Value to set. + * @returns Promise that resolves when the value is set. + */ +export async function setValueInJsonFile( + filePath: string, + jsonPath: JSONPath, + value: V, +): Promise { + const jsonc = await Deno.readTextFile(filePath); + const newJsonc = setValueInJson(jsonc, jsonPath, value); + await Deno.writeTextFile(filePath, newJsonc); +} + +/** + * Get the value at the given JSON path in the given JSON or JSONC string. + * @param jsonc JSON or JSONC string to get the value from. + * @param jsonPath JSON path to the value to get. + * @param defaultValue Default value to return if the value is not found. + * @returns Value at the given JSON path in the given JSON or JSONC string. + */ +export function getValueInJson( + jsonc: string, + jsonPath: JSONPath, + defaultValue: V | undefined = undefined, +): V | undefined { + const tree: Node | undefined = parseTree(jsonc); + if (tree === undefined) { + return defaultValue; + } + const value: Node | undefined = findNodeAtLocation(tree, jsonPath); + if (value === undefined) { + return defaultValue; + } + return getNodeValue(value); +} + +/** + * Get the value at the given JSON path in the given JSON or JSONC file. + * @param filePath Path to the JSON or JSONC file to get the value from. + * @param jsonPath JSON path to the value to get. + * @param defaultValue Default value to return if the file or the value is not + * found. + * @returns Promise that resolves to the value at the given JSON path in the + * given JSON or JSONC file. + */ +export async function getValueInJsonFile( + filePath: string, + jsonPath: JSONPath, + defaultValue: V | undefined = undefined, +): Promise { + const jsonc = await Deno.readTextFile(filePath); + return getValueInJson(jsonc, jsonPath, defaultValue); +} + +/** + * Get the version from the given `deno.json` or `deno.jsonc` file. + * @param filePath Path to the `deno.json` or `deno.jsonc` file to get the + * version from. + * @returns Promise that resolves to the version from the given `deno.json` or + * `deno.jsonc` file. + */ +export async function getVersionInDenoJsonFile( + filePath: string, +): Promise { + return await getValueInJsonFile( + filePath, + ["version"], + ); +} + +/** + * Set the version in the given `deno.json` or `deno.jsonc` file. + * @param filePath Path to the `deno.json` or `deno.jsonc` file to set the + * version in. + * @param version Version to set. + * @returns Promise that resolves when the version is set. + */ +export async function setVersionInDenoJsonFile( + filePath: string, + version: string, +): Promise { + await setValueInJsonFile( + filePath, + ["version"], + version, + ); +} diff --git a/shipit.ts b/shipit.ts index 18deb16..2f7b822 100644 --- a/shipit.ts +++ b/shipit.ts @@ -7,9 +7,12 @@ import { } from "./commit-regex.ts"; import * as colors from "@std/fmt/colors"; import * as semver from "@std/semver"; +import { swallow } from "@hugojosefson/fns/fn/swallow"; import git, { ROOT } from "./git.ts"; import github, { type Commits, generateReleaseNotes } from "./github.ts"; +import { setVersionInDenoJsonFile } from "./json.ts"; import { logHeader } from "./log.ts"; +import { run } from "@hugojosefson/run-simple"; if (!import.meta.main) { Deno.exit(0); @@ -96,6 +99,47 @@ if ( Deno.exit(0); } +logHeader("Updating version in any deno.json and deno.jsonc..."); + +// Sanity check: make sure there are still no uncommitted changes (there shouldn't be). +if (!(await git.isClean())) { + console.error( + `There are unexpectedly uncommitted changes in the middle of the release. Aborting. + +This is likely a bug in @hugojosefson/shipit — My bad. + +If you are able, please file a bug report at https://github.com/hugojosefson/shipit/issues +Include what you feel comfortable with, from the following: + +- The exact shipit command you ran, that failed. +- The output from the shipit command you ran. +- The output from the following commands, after the error: + git status + git diff + git diff --cached + git log + git fetch # only relevant with the first "git fetch" after the error + +Thank you! +`, + ); + Deno.exit(1); +} + +// Write the new version to any `deno.json` and `deno.jsonc`, if needed, and commit. +await setVersionInDenoJsonFile("deno.json", nextVer).catch( + swallow(Deno.errors.NotFound), +); +await setVersionInDenoJsonFile("deno.jsonc", nextVer).catch( + swallow(Deno.errors.NotFound), +); +if (!(await git.isClean())) { + console.log("Committing version change to deno.json*..."); + await run(["git", "add", "."]); + await run(["git", "commit", "-m", `chore: release ${nextVer}`]); + console.log("Version change committed."); +} + // Tag the new version. logHeader("Creating new remote tag..."); await git.tag(nextVer);