@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.
Files changed (4) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +160 -2
  3. package/package.json +30 -4
  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
- # Temporary Holding Version
1
+ # @sasjs/release
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }