@sasjs/release 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +160 -2
- package/package.json +30 -4
- package/release.mjs +500 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SASjs
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
10
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
|
11
|
+
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
12
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
13
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
14
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
15
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,161 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @sasjs/release
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Simple and safe release management. Releases from conventional commits, for any host. Zero dependencies, one file.
|
|
4
|
+
|
|
5
|
+
The version is the git tag. Nothing writes a version into `package.json`, nothing commits, nothing pushes to a branch: the tag names the commit that was merged, and the release notes are the commits it covers. GitHub, Gitea, GitLab and a laptop all run the same command.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @sasjs/release --dry-run # what it would tag, and the notes
|
|
9
|
+
npx @sasjs/release --tag --push-tag # cut it
|
|
10
|
+
npx @sasjs/release --tag --host gitlab --release
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Why not a release toolchain
|
|
14
|
+
|
|
15
|
+
`semantic-release` and `release-please` arrive with a few hundred packages, a plugin per job, and advisories that no one can fix (their copies of `npm`, `glob` and `undici` are bundled inside the tool). This is ~450 lines and installs nothing, so there is nothing to audit and nothing to upgrade. It also does not need a `gh` CLI, `curl`, `jq` or `tea`: the host calls are plain `fetch`.
|
|
16
|
+
|
|
17
|
+
### What it deliberately does not do
|
|
18
|
+
|
|
19
|
+
There is no release commit, so **no changelog file is written and `package.json` is not bumped in git**. Both of those mean a runner pushing to the default branch, which means a token with bypass on a protected branch, which is the permission that is hardest to justify and easiest to get wrong.
|
|
20
|
+
|
|
21
|
+
- **The changelog is the release page.** It is generated from the commits the tag covers, on every run, from git alone.
|
|
22
|
+
- If a file is wanted anyway, it does not have to be in git: attach the notes as an asset (`--notes-file NOTES.md --asset NOTES.md`), or generate the file at docs-build time from `git log <prev>..<tag>` - the same data, no write.
|
|
23
|
+
- **`package.json` is stamped in the publishing job, not in the repository**: `npm version "${TAG#v}" --no-git-tag-version` before `npm publish`. The tarball carries the right version and nothing is committed.
|
|
24
|
+
- The committed `package.json` therefore says `0.0.0` - meaning unset, not stale. A version there would be a claim the repository cannot keep true, since updating it is the commit this design refuses. `prepublishOnly` refuses to publish while the version is still `0.0.0`, so a hand-run publish fails loudly instead of putting `0.0.0` on the registry.
|
|
25
|
+
- Consequence: installing **from git** (`npm i github:sasjs/release`) or packing outside CI gives `0.0.0`. Install from the registry, or use the action at a pinned commit, which is what a consumer wants anyway.
|
|
26
|
+
|
|
27
|
+
Two things it does that the toolchain cannot:
|
|
28
|
+
|
|
29
|
+
- **A failed release is re-runnable.** If a run dies after the tag is cut - the push rejected, the API down, the runner killed - re-running with the same flags finishes it. The tag is reused instead of refused, and the notes are rebuilt from the commits the tag covers.
|
|
30
|
+
- **Nothing lands on your default branch.** No release commit, no `package.json` bump, no second push for the tag, so no protected-branch or app-token dance.
|
|
31
|
+
|
|
32
|
+
## Options
|
|
33
|
+
|
|
34
|
+
| Option | Meaning |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `--repo-dir <path>` | repository to work in (default: cwd) |
|
|
37
|
+
| `--tag-prefix <p>` | prefix before the version in the tag (default `v`) |
|
|
38
|
+
| `--since <ref>` | count commits after this ref (default: newest prefixed tag) |
|
|
39
|
+
| `--paths <a,b>` | only commits touching these paths - monorepos |
|
|
40
|
+
| `--level <l>` | force `patch`, `minor` or `major` instead of classifying |
|
|
41
|
+
| `--prerelease <id>` | cut `<version>-<id>.1`, or continue the last one (`beta.2` -> `beta.3`) |
|
|
42
|
+
| `--dry-run` | say what it would do; write nothing, send nothing |
|
|
43
|
+
| `--notes-file <path>` | write the release notes here |
|
|
44
|
+
| `--output <path>` | append `released`, `version`, `tag`, `level` as `key=value` |
|
|
45
|
+
| `--tag` | create the annotated tag (reused when it already names HEAD) |
|
|
46
|
+
| `--push-tag` | push the tag to `origin` |
|
|
47
|
+
| `--host <h>` | `none` (default), `github`, `gitea`, `gitlab` |
|
|
48
|
+
| `--release` | create the release on the host |
|
|
49
|
+
| `--repo <owner/name>` | repository on the host (default: from the git remote) |
|
|
50
|
+
| `--api-url <url>` | host API base, for a self-hosted instance |
|
|
51
|
+
| `--token-env <NAME>` | environment variable holding the token |
|
|
52
|
+
| `--asset <path>` | file to attach (repeatable; GitHub and Gitea upload it) |
|
|
53
|
+
| `--asset-link <n=url>` | link to attach (repeatable; the only asset shape GitLab has) |
|
|
54
|
+
|
|
55
|
+
Exit codes: `0` released or nothing to release, `2` usage, git or host error.
|
|
56
|
+
|
|
57
|
+
The result is printed as `key=value` lines, so a pipeline can feed it to whatever variable protocol it has - `$GITHUB_OUTPUT`, `$GITHUB_ENV`, a dotenv artifact, nothing at all - with `--output`.
|
|
58
|
+
|
|
59
|
+
## Permissions
|
|
60
|
+
|
|
61
|
+
The whole release is one credential: a token that may write contents on the repository. No branch is written, nothing is committed, and no workflow-file change is involved.
|
|
62
|
+
|
|
63
|
+
| What | Permission | Why it is needed |
|
|
64
|
+
| --- | --- | --- |
|
|
65
|
+
| Create the release | `contents: write` (GitHub, Gitea), Developer or the job token (GitLab) | creating a release is a write on the repository |
|
|
66
|
+
| Create the tag | the same grant | the forge creates the tag from `target_commitish` (GitHub, Gitea) or `ref` (GitLab), so the job needs no git credentials and does not run `git push` |
|
|
67
|
+
| Upload assets | the same grant | attached to the release |
|
|
68
|
+
| `npm publish` with OIDC | `id-token: write` | stays in the calling workflow, so provenance names the package's own repository |
|
|
69
|
+
|
|
70
|
+
A release therefore needs **one** call and no checkout credentials at all:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
# the host creates the tag at this commit and the release in one call
|
|
74
|
+
npx @sasjs/release --host github --release
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`--tag --push-tag` exists for the case where the tag must exist before the release does - building artefacts from it in a later job, for instance - and it costs a git credential. The minimal path does not use it.
|
|
78
|
+
|
|
79
|
+
A `permissions` block that grants `contents: write` to the release job is the same grant the release toolchain already asks for; what is lost is the app token that exists only to push a release commit.
|
|
80
|
+
|
|
81
|
+
## The version rule
|
|
82
|
+
|
|
83
|
+
`feat` is a minor, `fix`/`perf`/`refactor` a patch, `!` or a `BREAKING CHANGE:` footer a major - except before 1.0.0, where a breaking change is the minor. Anything else (`docs:`, `chore:`, `ci:`) is not a release, and if nothing classifies the tool says so and exits 0. `--level` covers a repository whose history is not conventionally formatted.
|
|
84
|
+
|
|
85
|
+
## GitHub
|
|
86
|
+
|
|
87
|
+
The repository ships a composite action, so a workflow is a step rather than a script:
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
permissions:
|
|
91
|
+
contents: write # the tag and the release
|
|
92
|
+
jobs:
|
|
93
|
+
release:
|
|
94
|
+
runs-on: ubuntu-latest
|
|
95
|
+
steps:
|
|
96
|
+
- uses: actions/checkout@<sha>
|
|
97
|
+
with:
|
|
98
|
+
fetch-depth: 0 # the range is classified from tags; a shallow clone sees one commit
|
|
99
|
+
- uses: sasjs/release@<sha>
|
|
100
|
+
with:
|
|
101
|
+
args: --tag --push-tag --host github --release
|
|
102
|
+
env:
|
|
103
|
+
GH_TOKEN: ${{ github.token }}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`fetch-depth: 0` is not optional: with a shallow clone there are no tags, every commit looks unreleased, and the version is invented.
|
|
107
|
+
|
|
108
|
+
## Gitea
|
|
109
|
+
|
|
110
|
+
Gitea Actions runs the same action, but the token and API base come from Gitea:
|
|
111
|
+
|
|
112
|
+
```yaml
|
|
113
|
+
- uses: sasjs/release@<sha>
|
|
114
|
+
with:
|
|
115
|
+
args: --tag --push-tag --host gitea --release --api-url ${{ gitea.server_url }}/api/v1
|
|
116
|
+
env:
|
|
117
|
+
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A tag created by the Actions user does not fire `on: push: tags` on Gitea, so any workflow that should react to a release (docs, a demo site) has to be dispatched from the same job.
|
|
121
|
+
|
|
122
|
+
## GitLab
|
|
123
|
+
|
|
124
|
+
No action syntax, so call the package directly. On a self-hosted instance, point `--api-url` at it:
|
|
125
|
+
|
|
126
|
+
```yaml
|
|
127
|
+
release:
|
|
128
|
+
image: node:22
|
|
129
|
+
rules:
|
|
130
|
+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
|
131
|
+
script:
|
|
132
|
+
- npx --yes @sasjs/release --host gitlab --release
|
|
133
|
+
--api-url "$CI_API_V4_URL" --repo "$CI_PROJECT_PATH"
|
|
134
|
+
--token-env CI_JOB_TOKEN --output release.env
|
|
135
|
+
--asset-link "app.zip=$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_SHA/download?job=build"
|
|
136
|
+
artifacts:
|
|
137
|
+
reports:
|
|
138
|
+
dotenv: release.env
|
|
139
|
+
variables:
|
|
140
|
+
GIT_DEPTH: '0' # GitLab clones 20 commits deep by default
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
GitLab releases carry links rather than uploaded files, which is what `--asset-link` is for; the usual link is the job artifact of the build that just ran. GitLab does not accept `Authorization: Bearer`, so the token goes in `PRIVATE-TOKEN`, or in `JOB-TOKEN` when it is `$CI_JOB_TOKEN` - which needs no personal token at all. `--token-header` overrides the choice.
|
|
144
|
+
|
|
145
|
+
## Adapters
|
|
146
|
+
|
|
147
|
+
`publish()` has one small function per host, because the shapes genuinely differ:
|
|
148
|
+
|
|
149
|
+
- **GitHub** - `POST /repos/{repo}/releases`, then each asset to the `upload_url` in the response (with the `{?name,label}` template stripped).
|
|
150
|
+
- **Gitea** - `POST {api}/repos/{repo}/releases`, multipart to `.../releases/{id}/assets`, and a `PATCH` for the body, because Gitea stores what it is given.
|
|
151
|
+
- **GitLab** - `POST {api}/projects/{path}/releases` with `ref` (the tag is created from it when missing), then `.../releases/{tag}/assets/links` per link.
|
|
152
|
+
|
|
153
|
+
A new host is one function plus an entry in `HOSTS`.
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
npm test # node --test: 22 tests, fixture repositories and a stub host
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The fixtures are real git repositories built per test, so the bump rules, the tag prefix, the path filter, prereleases and the re-run path are exercised against git rather than a mock. The host adapters are exercised against a stub HTTP server that records the requests.
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sasjs/release",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Host-neutral releases from conventional commits: version, notes, tag, release. Zero dependencies.",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"sasjs-release": "./release.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"release.mjs",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test",
|
|
19
|
+
"prepublishOnly": "node -e \"const v=require('./package.json').version; if (v === '0.0.0') { console.error('refusing to publish 0.0.0: the version is stamped from the tag (npm version <tag> --no-git-tag-version)'); process.exit(1) }\""
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/sasjs/release.git"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"release",
|
|
27
|
+
"semantic-release",
|
|
28
|
+
"conventional-commits",
|
|
29
|
+
"sasjs"
|
|
30
|
+
],
|
|
31
|
+
"private": false
|
|
32
|
+
}
|
package/release.mjs
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Decide and cut a release from conventional commits.
|
|
4
|
+
*
|
|
5
|
+
* The version is the git tag. Nothing in this script writes a version into
|
|
6
|
+
* package.json, commits, or pushes to a branch: the tag names the commit that
|
|
7
|
+
* was merged, and the release notes are the commits it covers. That removes the
|
|
8
|
+
* release commit, the branch push and the retry-outs that come with it.
|
|
9
|
+
*
|
|
10
|
+
* Host-neutral by construction: the git side (classify, version, notes, tag)
|
|
11
|
+
* needs nothing but git, and the release side talks to an API over plain
|
|
12
|
+
* https, so GitHub, Gitea, GitLab and anything with the same shape work
|
|
13
|
+
* without a CLI installed. `--host none` does the git work only.
|
|
14
|
+
*
|
|
15
|
+
* sasjs-release --dry-run
|
|
16
|
+
* sasjs-release --tag --push-tag --host github --release
|
|
17
|
+
* sasjs-release --tag --host gitlab --release --asset-link "app.zip=$URL"
|
|
18
|
+
*
|
|
19
|
+
* Exit 0: released, or nothing to release (`released=false` in --output).
|
|
20
|
+
* Exit 2: usage, git or host error; nothing is tagged when this is returned
|
|
21
|
+
* before the tag step, and the tag alone exists when it is returned after.
|
|
22
|
+
*/
|
|
23
|
+
import { execFileSync } from 'node:child_process'
|
|
24
|
+
import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'
|
|
25
|
+
import { basename, join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
const LEVELS = { major: 3, minor: 2, patch: 1 }
|
|
28
|
+
|
|
29
|
+
const HELP = `sasjs-release - releases from conventional commits
|
|
30
|
+
|
|
31
|
+
--repo-dir <path> repository to work in (default: cwd)
|
|
32
|
+
--tag-prefix <p> prefix before the version in the tag (default: v)
|
|
33
|
+
--since <ref> count commits after this ref (default: newest prefixed tag)
|
|
34
|
+
--paths <a,b> only commits touching these paths (monorepos)
|
|
35
|
+
--level <l> force patch|minor|major instead of classifying
|
|
36
|
+
--prerelease <id> cut <version>-<id>.1 (or continue the last one)
|
|
37
|
+
--dry-run say what it would do; write nothing, send nothing
|
|
38
|
+
--notes-file <path> write the release notes here
|
|
39
|
+
--output <path> append key=value lines: released, version, tag, level
|
|
40
|
+
--tag create the annotated tag (reused if it is already here)
|
|
41
|
+
--push-tag push the tag to origin
|
|
42
|
+
--host <h> none|github|gitea|gitlab (default: none)
|
|
43
|
+
--release create the host release (github/gitea/gitlab)
|
|
44
|
+
--repo <owner/name> repository on the host (default: from the remote)
|
|
45
|
+
--api-url <url> host API base (default: per host)
|
|
46
|
+
--token-env <NAME> environment variable holding the token
|
|
47
|
+
--token-header <h> which header carries it (default: per host)
|
|
48
|
+
--asset <path> file to upload to the release (repeatable)
|
|
49
|
+
--asset-link <n=url> link to attach to the release (repeatable)
|
|
50
|
+
--title <text> release title (default: the tag)
|
|
51
|
+
--help this text
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
const flag = (name) => process.argv.includes(`--${name}`)
|
|
55
|
+
|
|
56
|
+
const arg = (name, fallback = '') => {
|
|
57
|
+
const at = process.argv.indexOf(`--${name}`)
|
|
58
|
+
if (at === -1) return fallback
|
|
59
|
+
const value = process.argv[at + 1]
|
|
60
|
+
if (value === undefined || value.startsWith('--')) fail(`--${name} needs a value`)
|
|
61
|
+
return value
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const args = (name) =>
|
|
65
|
+
process.argv.reduce((all, token, index) => {
|
|
66
|
+
if (token === `--${name}`) {
|
|
67
|
+
const value = process.argv[index + 1]
|
|
68
|
+
if (value === undefined || value.startsWith('--'))
|
|
69
|
+
fail(`--${name} needs a value`)
|
|
70
|
+
all.push(value)
|
|
71
|
+
}
|
|
72
|
+
return all
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
function fail(message) {
|
|
76
|
+
console.error(`sasjs-release: ${message}`)
|
|
77
|
+
process.exit(2)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// stderr is captured rather than inherited: several probes (a missing `origin`,
|
|
81
|
+
// an absent tag) are expected to fail, and their noise is not a finding
|
|
82
|
+
const git = (cwd, ...argv) =>
|
|
83
|
+
execFileSync('git', argv, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
|
|
84
|
+
|
|
85
|
+
const attempt = (fn) => {
|
|
86
|
+
try {
|
|
87
|
+
return fn()
|
|
88
|
+
} catch {
|
|
89
|
+
return ''
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The commit range to classify: after the newest tag carrying our prefix. */
|
|
94
|
+
const latestTag = (cwd, prefix) =>
|
|
95
|
+
attempt(() =>
|
|
96
|
+
git(cwd, 'describe', '--tags', '--abbrev=0', '--match', `${prefix}[0-9]*`)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
/** Subject and body per commit, newest last. NUL and 0x01 separate the fields. */
|
|
100
|
+
function commits(cwd, from, paths, to = 'HEAD') {
|
|
101
|
+
const range = from ? `${from}..${to}` : to
|
|
102
|
+
const raw = attempt(() =>
|
|
103
|
+
git(cwd, 'log', range, '--pretty=format:%s%x00%b%x01', '--', ...paths)
|
|
104
|
+
)
|
|
105
|
+
if (!raw) return []
|
|
106
|
+
return raw
|
|
107
|
+
.split('\x01')
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.map((entry) => {
|
|
110
|
+
const [subject, body = ''] = entry.split('\x00')
|
|
111
|
+
return { subject: subject.trim(), body: body.trim() }
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const SUBJECT = /^([a-z]+)(\([^)]*\))?(!)?:\s*(.+)$/i
|
|
116
|
+
const TYPES = { feat: 'feat', fix: 'fix', perf: 'perf', refactor: 'refactor' }
|
|
117
|
+
const TITLES = {
|
|
118
|
+
breaking: 'BREAKING CHANGES',
|
|
119
|
+
feat: 'Features',
|
|
120
|
+
fix: 'Bug Fixes',
|
|
121
|
+
perf: 'Performance',
|
|
122
|
+
refactor: 'Refactors'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function classify({ subject, body }) {
|
|
126
|
+
const match = subject.match(SUBJECT)
|
|
127
|
+
if (!match) return { type: 'other', breaking: false, text: subject }
|
|
128
|
+
const [, type, , bang, text] = match
|
|
129
|
+
return {
|
|
130
|
+
type: type.toLowerCase(),
|
|
131
|
+
breaking: Boolean(bang) || /^BREAKING[ -]CHANGE:/m.test(body),
|
|
132
|
+
text: text.trim()
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The stable part of a tag's version, so a prerelease base still bumps. */
|
|
137
|
+
const stable = (version) => version.split('-')[0]
|
|
138
|
+
|
|
139
|
+
function bump(base, level) {
|
|
140
|
+
const [major, minor, patch] = stable(base).split('.').map(Number)
|
|
141
|
+
if (level === 'major') return `${major + 1}.0.0`
|
|
142
|
+
if (level === 'minor') return `${major}.${minor + 1}.0`
|
|
143
|
+
return `${major}.${minor}.${patch + 1}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A prerelease continues the last prerelease of the same base version, so
|
|
148
|
+
* re-running before the stable cut moves beta.1 -> beta.2 rather than
|
|
149
|
+
* producing a second beta.1.
|
|
150
|
+
*/
|
|
151
|
+
function bumpPrerelease(base, id, previous) {
|
|
152
|
+
base = stable(base)
|
|
153
|
+
if (previous) {
|
|
154
|
+
const match = previous.match(/^(\d+)\.(\d+)\.(\d+)-([^.]+)\.(\d+)$/)
|
|
155
|
+
if (match) {
|
|
156
|
+
const [, major, minor, patch, previousId, n] = match
|
|
157
|
+
const at = `${major}.${minor}.${patch}`
|
|
158
|
+
const next = Number(n) + 1
|
|
159
|
+
if (at === base && previousId === id) return `${base}-${id}.${next}`
|
|
160
|
+
return `${base}-${id}.1`
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return `${base}-${id}.1`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** https://host/owner/repo from any remote form, so notes can link to diffs. */
|
|
167
|
+
function remoteUrl(cwd) {
|
|
168
|
+
const raw = attempt(() => git(cwd, 'remote', 'get-url', 'origin'))
|
|
169
|
+
if (!raw) return ''
|
|
170
|
+
const match = raw.match(/^(?:[\w-]+:\/\/)?(?:[^@/]+@)?([^:/]+)[:/](.+?)(?:\.git)?$/)
|
|
171
|
+
if (!match) return ''
|
|
172
|
+
const [, host, path] = match
|
|
173
|
+
return `https://${host}/${path.replace(/\.git$/, '')}`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function notes(version, parsed, previous, repo, date) {
|
|
177
|
+
const link = repo
|
|
178
|
+
? previous
|
|
179
|
+
? `${repo}/compare/${previous}...v${version}`
|
|
180
|
+
: `${repo}/releases/tag/v${version}`
|
|
181
|
+
: ''
|
|
182
|
+
const groups = { breaking: [], feat: [], fix: [], perf: [], refactor: [] }
|
|
183
|
+
for (const commit of parsed) {
|
|
184
|
+
if (commit.breaking) groups.breaking.push(commit)
|
|
185
|
+
if (commit.type in TYPES) groups[commit.type].push(commit)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const heading = link ? `## [${version}](${link})` : `## ${version}`
|
|
189
|
+
const lines = [`${heading}${date ? ` (${date})` : ''}`, '']
|
|
190
|
+
for (const key of Object.keys(TITLES)) {
|
|
191
|
+
if (!groups[key].length) continue
|
|
192
|
+
lines.push(`### ${TITLES[key]}`, '')
|
|
193
|
+
for (const commit of groups[key]) lines.push(`* ${commit.text}`)
|
|
194
|
+
lines.push('')
|
|
195
|
+
}
|
|
196
|
+
return lines.join('\n')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Multipart without dependencies: node's own FormData and Blob, so an asset
|
|
201
|
+
* upload needs no curl on the runner and no library in the tree.
|
|
202
|
+
*/
|
|
203
|
+
async function upload(url, headers, file, field) {
|
|
204
|
+
const body = new FormData()
|
|
205
|
+
const bytes = readFileSync(file)
|
|
206
|
+
body.append(field, new Blob([bytes]), basename(file))
|
|
207
|
+
const response = await request(url, { method: 'POST', headers, body })
|
|
208
|
+
return response
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Each forge spells its credential differently, and getting this wrong is a
|
|
213
|
+
* 401 that looks like a permissions problem: GitHub and Gitea take an
|
|
214
|
+
* Authorization scheme, GitLab takes its own header and rejects Bearer. A
|
|
215
|
+
* GitLab job token works too - it needs no personal token at all.
|
|
216
|
+
*/
|
|
217
|
+
function authHeaders(host, token, header) {
|
|
218
|
+
if (!token) return {}
|
|
219
|
+
if (host === 'gitlab')
|
|
220
|
+
return { [header || (process.env.CI_JOB_TOKEN === token ? 'job-token' : 'private-token')]: token }
|
|
221
|
+
return { authorization: host === 'gitea' ? `token ${token}` : `Bearer ${token}` }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function request(url, { method = 'GET', headers = {}, body, json } = {}) {
|
|
225
|
+
headers = { accept: 'application/json', ...headers }
|
|
226
|
+
if (json !== undefined) headers['content-type'] = 'application/json'
|
|
227
|
+
|
|
228
|
+
const response = await fetch(url, {
|
|
229
|
+
method,
|
|
230
|
+
headers,
|
|
231
|
+
body: json !== undefined ? JSON.stringify(json) : body
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const text = await response.text()
|
|
235
|
+
if (!response.ok)
|
|
236
|
+
fail(`${method} ${url} answered ${response.status}: ${text.slice(0, 500)}`)
|
|
237
|
+
return text ? JSON.parse(text) : {}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const HOSTS = {
|
|
241
|
+
github: {
|
|
242
|
+
api: 'https://api.github.com',
|
|
243
|
+
token: 'GH_TOKEN'
|
|
244
|
+
},
|
|
245
|
+
gitea: {
|
|
246
|
+
api: '',
|
|
247
|
+
token: 'GITEA_TOKEN'
|
|
248
|
+
},
|
|
249
|
+
gitlab: {
|
|
250
|
+
api: 'https://gitlab.com/api/v4',
|
|
251
|
+
token: 'GITLAB_TOKEN'
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Create (or find) the release and attach what was asked for. Each host is a
|
|
257
|
+
* small function because the shapes genuinely differ: GitHub needs a separate
|
|
258
|
+
* upload endpoint, Gitea posts multipart to its own asset URL, and GitLab has
|
|
259
|
+
* no asset upload at all - a release carries links, which is why --asset-link
|
|
260
|
+
* exists.
|
|
261
|
+
*/
|
|
262
|
+
async function publish({ host, api, token, header, repo, tag, body, title, files, links, commit }) {
|
|
263
|
+
const headers = authHeaders(host, token, header)
|
|
264
|
+
const encoded = repo.replace('/', '%2F')
|
|
265
|
+
|
|
266
|
+
if (host === 'github') {
|
|
267
|
+
const base = api || HOSTS.github.api
|
|
268
|
+
// target_commitish is what makes the tag exist at all: the API creates it
|
|
269
|
+
// when tag_name is new, so the job needs no git credentials and never
|
|
270
|
+
// pushes to the repository. Unused when the tag is already there.
|
|
271
|
+
const release = await request(`${base}/repos/${repo}/releases`, {
|
|
272
|
+
method: 'POST',
|
|
273
|
+
headers,
|
|
274
|
+
json: { tag_name: tag, target_commitish: commit, name: title, body }
|
|
275
|
+
})
|
|
276
|
+
for (const file of files) {
|
|
277
|
+
const url = String(release.upload_url).replace(/\{.*$/, '')
|
|
278
|
+
await upload(`${url}?name=${encodeURIComponent(basename(file))}`, headers, file, 'file')
|
|
279
|
+
}
|
|
280
|
+
return release
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (host === 'gitea') {
|
|
284
|
+
if (!api) fail('--host gitea needs --api-url (https://your-host/api/v1)')
|
|
285
|
+
const release = await request(`${api}/repos/${repo}/releases`, {
|
|
286
|
+
method: 'POST',
|
|
287
|
+
headers,
|
|
288
|
+
json: { tag_name: tag, target_commitish: commit, name: title, body, draft: false }
|
|
289
|
+
})
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
await upload(
|
|
292
|
+
`${api}/repos/${repo}/releases/${release.id}/assets?name=${encodeURIComponent(basename(file))}`,
|
|
293
|
+
headers,
|
|
294
|
+
file,
|
|
295
|
+
'attachment'
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
if (body !== release.body) {
|
|
299
|
+
await request(`${api}/repos/${repo}/releases/${release.id}`, {
|
|
300
|
+
method: 'PATCH',
|
|
301
|
+
headers,
|
|
302
|
+
json: { body }
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
return release
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (host === 'gitlab') {
|
|
309
|
+
const base = api || HOSTS.gitlab.api
|
|
310
|
+
// GitLab is the same shape: `ref` is what creates the tag when tag_name is
|
|
311
|
+
// new, and without it the call fails on a tag that does not exist yet.
|
|
312
|
+
const release = await request(`${base}/projects/${encoded}/releases`, {
|
|
313
|
+
method: 'POST',
|
|
314
|
+
headers,
|
|
315
|
+
json: { name: title, tag_name: tag, ref: commit, description: body }
|
|
316
|
+
})
|
|
317
|
+
for (const link of links) {
|
|
318
|
+
await request(`${base}/projects/${encoded}/releases/${encodeURIComponent(tag)}/assets/links`, {
|
|
319
|
+
method: 'POST',
|
|
320
|
+
headers,
|
|
321
|
+
json: { name: link.name, url: link.url }
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
return release
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
fail(`unknown --host ${host}`)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function main() {
|
|
331
|
+
if (flag('help')) {
|
|
332
|
+
console.log(HELP)
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const cwd = arg('repo-dir', process.cwd())
|
|
337
|
+
const prefix = arg('tag-prefix', 'v')
|
|
338
|
+
const dry = flag('dry-run')
|
|
339
|
+
const host = arg('host', 'none')
|
|
340
|
+
const prerelease = arg('prerelease')
|
|
341
|
+
const forcedLevel = arg('level')
|
|
342
|
+
const paths = arg('paths') ? arg('paths').split(',').filter(Boolean) : []
|
|
343
|
+
|
|
344
|
+
if (forcedLevel && !(forcedLevel in LEVELS)) fail('--level must be patch, minor or major')
|
|
345
|
+
if (host !== 'none' && !(host in HOSTS)) fail('--host must be none, github, gitea or gitlab')
|
|
346
|
+
|
|
347
|
+
const since = arg('since')
|
|
348
|
+
// an unresolvable --since used to read as "nothing to release", which is the
|
|
349
|
+
// worst possible answer: a shallow clone or a typo would silently ship no
|
|
350
|
+
// release. Name the ref instead.
|
|
351
|
+
if (since && !attempt(() => git(cwd, 'rev-parse', '-q', '--verify', `${since}^{commit}`)))
|
|
352
|
+
fail(`--since ${since} is not a commit in this repository (a shallow clone needs a full fetch)`)
|
|
353
|
+
|
|
354
|
+
const previous = since || latestTag(cwd, prefix)
|
|
355
|
+
const head = git(cwd, 'rev-parse', 'HEAD')
|
|
356
|
+
const atHead = (ref) =>
|
|
357
|
+
Boolean(ref) && attempt(() => git(cwd, 'rev-parse', '-q', '--verify', `${ref}^{commit}`)) === head
|
|
358
|
+
|
|
359
|
+
const parsed = commits(cwd, arg('since') ? arg('since') : previous, paths).map(classify)
|
|
360
|
+
const releasable = parsed.filter((c) => c.breaking || c.type in TYPES)
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A run can end after the tag is cut - the push rejected, the release API
|
|
364
|
+
* down, the runner killed - and the tag then names the commit while nothing
|
|
365
|
+
* else exists. `--tag`, `--push-tag` and `--release` are all idempotent, so
|
|
366
|
+
* that state is finished rather than reported as "nothing to release", which
|
|
367
|
+
* is what makes a failure re-runnable instead of a hand repair.
|
|
368
|
+
*/
|
|
369
|
+
const finishing =
|
|
370
|
+
!releasable.length &&
|
|
371
|
+
!forcedLevel &&
|
|
372
|
+
(flag('tag') || flag('push-tag') || flag('release')) &&
|
|
373
|
+
atHead(previous)
|
|
374
|
+
|
|
375
|
+
if (!releasable.length && !forcedLevel && !finishing) {
|
|
376
|
+
console.log(`nothing to release since ${previous || 'the first commit'}`)
|
|
377
|
+
output({ released: false })
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const base = previous.replace(new RegExp(`^${prefix}`), '') || '0.0.0'
|
|
382
|
+
// A breaking change is a major, except before 1.0.0 where it is the minor -
|
|
383
|
+
// the convention every pre-1.0 tool in this space follows.
|
|
384
|
+
const level =
|
|
385
|
+
forcedLevel ||
|
|
386
|
+
(releasable.some((c) => c.breaking)
|
|
387
|
+
? Number(base.split('.')[0]) === 0
|
|
388
|
+
? 'minor'
|
|
389
|
+
: 'major'
|
|
390
|
+
: releasable.some((c) => c.type === 'feat')
|
|
391
|
+
? 'minor'
|
|
392
|
+
: 'patch')
|
|
393
|
+
|
|
394
|
+
// A tag that is already a prerelease names a version that has not shipped,
|
|
395
|
+
// so the target does not move again: beta.2 plus a fix is beta.3, not
|
|
396
|
+
// 1.8.1-beta.1.
|
|
397
|
+
const inPrerelease = /^\d+\.\d+\.\d+-[^.]+\.\d+$/.test(base)
|
|
398
|
+
const target = inPrerelease ? stable(base) : bump(base, level)
|
|
399
|
+
const version = finishing
|
|
400
|
+
? base
|
|
401
|
+
: prerelease
|
|
402
|
+
? bumpPrerelease(target, prerelease, base)
|
|
403
|
+
: target
|
|
404
|
+
const tag = finishing ? previous : `${prefix}${version}`
|
|
405
|
+
const remote = remoteUrl(cwd)
|
|
406
|
+
const repo = arg('repo') || (remote.match(/https:\/\/[^/]+\/(.+)$/) || [])[1] || ''
|
|
407
|
+
// the notes for a finished tag are the commits it covers, not the (empty)
|
|
408
|
+
// ones after it
|
|
409
|
+
const covered = finishing
|
|
410
|
+
? {
|
|
411
|
+
from: attempt(() => git(cwd, 'describe', '--tags', '--abbrev=0', '--match', `${prefix}[0-9]*`, `${previous}^`)),
|
|
412
|
+
commits: commits(cwd, attempt(() => git(cwd, 'describe', '--tags', '--abbrev=0', '--match', `${prefix}[0-9]*`, `${previous}^`)), [], previous).map(classify)
|
|
413
|
+
}
|
|
414
|
+
: { from: previous, commits: parsed }
|
|
415
|
+
|
|
416
|
+
const body = notes(
|
|
417
|
+
version,
|
|
418
|
+
covered.commits,
|
|
419
|
+
covered.from,
|
|
420
|
+
remote,
|
|
421
|
+
new Date().toISOString().slice(0, 10)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if (finishing)
|
|
425
|
+
console.log(`${tag} already names HEAD: finishing that release`)
|
|
426
|
+
else
|
|
427
|
+
console.log(`${level} release: ${previous || 'none'} -> ${tag} (${releasable.length} commits)`)
|
|
428
|
+
console.log(body)
|
|
429
|
+
|
|
430
|
+
if (dry) {
|
|
431
|
+
console.log('--dry-run: nothing tagged, nothing sent')
|
|
432
|
+
output({ released: false, version, tag, level })
|
|
433
|
+
return
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const notesFile = arg('notes-file')
|
|
437
|
+
if (notesFile) writeFileSync(notesFile, `${body}\n`)
|
|
438
|
+
output({ released: true, version, tag, level })
|
|
439
|
+
|
|
440
|
+
if (flag('tag')) {
|
|
441
|
+
// Reuse a tag that already points at HEAD: a run that failed after the tag
|
|
442
|
+
// was cut can be finished by re-running, which a release tool that refuses
|
|
443
|
+
// an existing tag cannot do.
|
|
444
|
+
const existing = attempt(() => git(cwd, 'rev-parse', '-q', '--verify', `${tag}^{commit}`))
|
|
445
|
+
if (existing && existing === head) console.log(`tag ${tag} already names HEAD`)
|
|
446
|
+
else if (existing) fail(`tag ${tag} exists and names ${existing}, not HEAD`)
|
|
447
|
+
else
|
|
448
|
+
git(
|
|
449
|
+
cwd,
|
|
450
|
+
'-c', 'user.name=sasjs',
|
|
451
|
+
'-c', 'user.email=sasjs@4gl.io',
|
|
452
|
+
'tag', '-a', tag, '-m', tag
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (flag('push-tag')) git(cwd, 'push', 'origin', tag)
|
|
457
|
+
|
|
458
|
+
if (flag('release')) {
|
|
459
|
+
if (host === 'none') fail('--release needs --host')
|
|
460
|
+
const token = process.env[arg('token-env', HOSTS[host].token)]
|
|
461
|
+
if (!token) fail(`no token: set ${arg('token-env', HOSTS[host].token)}`)
|
|
462
|
+
const links = args('asset-link').map((pair) => {
|
|
463
|
+
const at = pair.indexOf('=')
|
|
464
|
+
if (at === -1) fail('--asset-link needs name=url')
|
|
465
|
+
return { name: pair.slice(0, at), url: pair.slice(at + 1) }
|
|
466
|
+
})
|
|
467
|
+
await publish({
|
|
468
|
+
host,
|
|
469
|
+
api: arg('api-url'),
|
|
470
|
+
token,
|
|
471
|
+
header: arg('token-header'),
|
|
472
|
+
repo,
|
|
473
|
+
tag,
|
|
474
|
+
body,
|
|
475
|
+
title: arg('title', tag),
|
|
476
|
+
files: args('asset'),
|
|
477
|
+
links,
|
|
478
|
+
commit: head
|
|
479
|
+
})
|
|
480
|
+
console.log(`released ${tag} on ${host}`)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* The result, on stdout and - when asked - as key=value lines a workflow can
|
|
486
|
+
* feed to whatever protocol it has (GITHUB_OUTPUT, GITHUB_ENV, a dotenv
|
|
487
|
+
* artifact, or nothing at all).
|
|
488
|
+
*/
|
|
489
|
+
function output(values) {
|
|
490
|
+
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`)
|
|
491
|
+
for (const line of lines) console.log(line)
|
|
492
|
+
const path = arg('output')
|
|
493
|
+
if (path) appendFileSync(path, `${lines.join('\n')}\n`)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
try {
|
|
497
|
+
await main()
|
|
498
|
+
} catch (error) {
|
|
499
|
+
fail(error.stderr ? String(error.stderr).trim() : error.message)
|
|
500
|
+
}
|