Skip to content

Commit be5bd6e

Browse files
committed
ci(release): dispatch releases by hand and add an AGENTS.md
Releases were published locally with `pnpm release` (changelogen --release --push --publish). They now go through .github/workflows/release.yml, dispatched by hand with an explicit version: it verifies the version, runs the gate, builds, lets changelogen write CHANGELOG.md and bump + commit + tag `v<version>`, pushes, creates the GitHub release and publishes to npm with `--provenance` over OIDC trusted publishing. Because nothing triggers on a tag any more, a pushed tag publishes nothing — a local tag can no longer publish a second time, and the old path skipped provenance. The `release` script is replaced by `release:check` and `release:preview`. Needs a trusted publisher configured for this repo + `release.yml` on npmjs.com before the first dispatch (the package has to already exist there). Also adds AGENTS.md so an agent can orient itself quickly.
1 parent 8469ca6 commit be5bd6e

6 files changed

Lines changed: 282 additions & 1 deletion

File tree

‎.github/workflows/release.yml‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
name: Release
2+
3+
# Manual, version-first release. Dispatch with the version to ship:
4+
# * verifies the version, then lints, type-checks and tests
5+
# * changelogen derives the changelog from conventional commits, bumps package.json,
6+
# commits and tags `v<version>`
7+
# * the commit and tag are pushed, and a GitHub release is created from the changelog section
8+
# * `@namesmt/utils-lambda` is published to npm with trusted publishing (OIDC — no token)
9+
#
10+
# One-time setup before the first run:
11+
# * publish the package once by hand: npm only lets you configure a trusted publisher for a
12+
# package that already exists
13+
# * on npmjs.com → the package → Settings → Trusted Publisher, add this repository with the
14+
# workflow filename `release.yml`
15+
# * a public repository gives `npm publish --provenance` an attestation that links the tarball
16+
# to this workflow run (drop the flag if the repository is ever made private again)
17+
#
18+
# Cut from starter-ts/.github/workflows/release.yml — keep the two in sync.
19+
20+
on:
21+
workflow_dispatch:
22+
inputs:
23+
version:
24+
description: Version to release, without a leading v (e.g. 0.2.0)
25+
required: true
26+
type: string
27+
dry-run:
28+
description: Stop before pushing, releasing and publishing
29+
required: false
30+
type: boolean
31+
default: false
32+
33+
permissions:
34+
contents: write
35+
id-token: write
36+
37+
concurrency:
38+
group: release
39+
cancel-in-progress: false
40+
41+
jobs:
42+
release:
43+
runs-on: ubuntu-latest
44+
env:
45+
VERSION: ${{ inputs.version }}
46+
TAG: v${{ inputs.version }}
47+
steps:
48+
- name: Checkout
49+
uses: actions/checkout@v7
50+
with:
51+
# changelogen needs the tags and the history to build the changelog
52+
fetch-depth: 0
53+
54+
- name: Setup pnpm
55+
uses: pnpm/action-setup@v6
56+
57+
- name: Setup Node
58+
uses: actions/setup-node@v7
59+
with:
60+
node-version: 24
61+
registry-url: https://registry.npmjs.org
62+
# never cache in a release build
63+
package-manager-cache: false
64+
65+
- name: Use a recent npm (trusted publishing needs >= 11.5.1)
66+
run: npm install -g npm@latest
67+
68+
- name: Check the requested version
69+
run: node scripts/check-release-version.mjs "$VERSION"
70+
71+
- name: Install
72+
run: pnpm install --frozen-lockfile
73+
74+
- name: Lint, types and tests
75+
run: pnpm run check
76+
77+
- name: Build
78+
run: pnpm run build
79+
80+
- name: Changelog, version bump, commit and tag
81+
run: |
82+
# the runner has no git identity, and changelogen will happily exit 0 after a
83+
# failed commit — so set one, then assert the release actually happened
84+
git config user.name "github-actions[bot]"
85+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
86+
npx -y changelogen@latest --release -r "$VERSION" --no-github --clean --hideAuthorEmail
87+
git tag --points-at HEAD | grep -qx "$TAG" || { echo "::error::changelogen did not create $TAG on a release commit"; exit 1; }
88+
test "$(node -p "require('./package.json').version")" = "$VERSION" || { echo "::error::package.json was not bumped to $VERSION"; exit 1; }
89+
90+
- name: Push the commit and the tag
91+
if: ${{ !inputs.dry-run }}
92+
run: |
93+
# someone may have pushed while this ran: replay the release commit on top, then push
94+
for attempt in 1 2 3; do
95+
git push --follow-tags && exit 0
96+
echo "push rejected (attempt $attempt) — rebasing on origin/main"
97+
git fetch --quiet origin main
98+
git rebase --autostash --quiet origin/main
99+
# the rebase rewrote the release commit, so the tag has to follow it
100+
git tag -f -a "$TAG" -m "$TAG"
101+
done
102+
echo "could not push the release commit" >&2
103+
exit 1
104+
105+
- name: Create the GitHub release
106+
if: ${{ !inputs.dry-run }}
107+
env:
108+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
109+
run: |
110+
# a process substitution would swallow a failure here, and empty
111+
# notes still create a release — so check the body before using it
112+
NOTES="$(mktemp)"
113+
node scripts/release-notes.mjs "$VERSION" > "$NOTES"
114+
test -s "$NOTES" || { echo "::error::the release notes for $VERSION came out empty"; exit 1; }
115+
gh release create "$TAG" \
116+
--title "$TAG" \
117+
--notes-file "$NOTES"
118+
119+
- name: Publish to npm
120+
if: ${{ !inputs.dry-run }}
121+
run: |
122+
npm publish --access public --provenance
123+
# a trusted publisher can be configured to stage instead of publishing live, in which
124+
# case npm accepts the upload and holds it for approval. Verify, and say which it was.
125+
for attempt in 1 2 3 4 5 6; do
126+
if npm view "$(node -p "require('./package.json').name")@$VERSION" version >/dev/null 2>&1; then
127+
echo "live on npm: $(node -p "require('./package.json').name")@$VERSION"
128+
exit 0
129+
fi
130+
sleep 10
131+
done
132+
echo "::warning::$VERSION is not visible on the registry yet — check npmjs.com for a staged publish waiting for approval"
133+
134+
- name: Summary
135+
run: |
136+
{
137+
echo "### $TAG$( [ "${{ inputs.dry-run }}" = "true" ] && echo ' (dry run)')"
138+
echo
139+
echo "Package: \`$(node -p "require('./package.json').name")@$VERSION\`"
140+
} >> "$GITHUB_STEP_SUMMARY"

‎AGENTS.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# AGENTS.md
2+
3+
`@namesmt/utils-lambda` — AWS Lambda helpers and types: request/trigger event types, fake API
4+
Gateway events, gzip/brotli response compression, payload decoding. Node >= 22, ESM only, `#src/*`
5+
import aliases, [tsdown](https://github.com/rolldown/tsdown) build, [Vitest](https://vitest.dev) tests.
6+
7+
## Commands
8+
9+
```sh
10+
pnpm run lint # eslint (@antfu/eslint-config) — it also owns formatting
11+
pnpm run test # vitest in watch mode
12+
pnpm run test:types # tsc --noEmit --skipLibCheck
13+
pnpm run check # lint + test:types + vitest run --coverage — the release gate
14+
pnpm run build # tsdown -> dist/index.mjs + dist/index.d.mts
15+
pnpm exec vitest run # one-shot suite, without coverage
16+
pnpm run release:check 0.2.0 # version must be semver and greater than the current one
17+
pnpm run release:preview # print the changelog the next release would get
18+
```
19+
20+
## Structure
21+
22+
- `src/index.ts` re-exports `./types` and `./utils`.
23+
- `src/utils.ts` — runtime helpers (`fakeEvent*`, `eventMethodUrl`, `pickEventContextV2`,
24+
`compressV2*`, `decompress*`, `decodeResponseV2*`, `decodePayload`).
25+
- `src/types.ts` — event unions (`LambdaEvent`, `LambdaRequestEvent`, `LambdaTriggerEvent`,
26+
`CommonTriggerEventsMap`) plus the local `LatticeProxyEventV2` types.
27+
- `src/utils.test.ts` — tests sit next to their source, not in `test/`.
28+
- `tsdown.config.ts` / `vitest.config.ts` — build/test config; `dist/` is built, never committed.
29+
- `.github/workflows/ci.yml` runs lint + types + `pnpm test` on push/PR to `main`; `release.yml` is
30+
manual (below). `scripts/` holds the two release helpers.
31+
32+
## Conventions
33+
34+
- Conventional commits (`feat:`, `fix:`, `chore:`, …) — the changelog is derived from them.
35+
- ESLint via `@antfu/eslint-config` owns formatting: no Prettier, single quotes, 2-space indent,
36+
sorted imports with no blank lines. `lint-staged` runs `eslint --fix` on commit.
37+
- ESM only: `"type": "module"` with an `import`-only `exports` map; do not add a CJS build.
38+
- Keep the local `LatticeProxyEventV2` types in `src/types.ts` until `@types/aws-lambda` ships them.
39+
40+
## Releasing
41+
42+
Version-first and manual: dispatch **Actions → Release → Run workflow** with the version — that is the only publish path, a pushed tag publishes nothing.
43+
`release.yml` runs `pnpm run check`, builds, then `npx -y changelogen@latest` bumps `package.json`, writes `CHANGELOG.md`, commits and tags `v<version>`, after which the run pushes, creates the GitHub release and publishes via OIDC trusted publishing.
44+
`dry-run` skips only the push, GitHub release and npm publish — the changelogen version bump, commit and tag are still created locally.
45+
One-time trusted-publisher setup is in the README.
46+
47+
## Gotchas
48+
49+
- `pnpm test` watches locally; use `pnpm exec vitest run` for a one-shot.
50+
- `release.yml` runs on Node 24, `ci.yml` on Node 22.
51+
- `prepublishOnly` runs `pnpm run build`, so a manual `npm publish` rebuilds after the workflow's build.
52+
- changelogen `--clean` fails when `git status --porcelain` is non-empty; ignored files such as
53+
`dist/` do not count.

‎README.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@ import { decodeResponse } from '@namesmt/utils-lambda'
3333
## Roadmap
3434
- [ ] Become the legendary 10000x developer
3535

36+
## Releasing
37+
Releases are version-first and dispatched by hand: one workflow run does the whole release,
38+
so a `git push` on its own never publishes anything.
39+
40+
1. Go to **Actions → Release → Run workflow** and give it the version to ship, e.g. `0.2.0`.
41+
2. [`.github/workflows/release.yml`](.github/workflows/release.yml) verifies the version, runs
42+
`pnpm run check`, builds, then lets [changelogen](https://github.com/unjs/changelogen) write
43+
the changelog, bump `package.json`, commit and tag `v<version>`. It pushes that commit and
44+
tag, creates the GitHub release, and publishes to npm with a short-lived
45+
[OIDC](https://docs.npmjs.com/generating-provenance-statements) token and `--provenance`.
46+
47+
Tick **dry-run** to do everything up to the commit and stop there — nothing is written back.
48+
49+
Locally, `pnpm run release:check <version>` validates a version against `package.json`, and
50+
`pnpm run release:preview` prints the changelog the next release would get.
51+
52+
One-time setup: publish the package once by hand (npm only offers a trusted publisher for a
53+
package that already exists), then on npmjs.com enable **Settings → Publishing access → Trusted
54+
Publishing** for `namesmt/utils-lambda` with the workflow filename `release.yml`.
55+
3656
## License [![License][license-src]][license-href]
3757
[MIT](./LICENSE) License © 2024 [NamesMT](https://github.com/NamesMT)
3858

‎package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@
4343
"test:types": "tsc --noEmit --skipLibCheck",
4444
"check": "pnpm lint && pnpm test:types && vitest run --coverage",
4545
"build": "tsdown",
46-
"release": "pnpm dlx changelogen@latest --release --push --publish",
46+
"release:check": "node scripts/check-release-version.mjs",
47+
"release:preview": "pnpm dlx changelogen@latest",
4748
"prepare": "simple-git-hooks",
4849
"prepublishOnly": "pnpm run build"
4950
},

‎scripts/check-release-version.mjs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env node
2+
/** Validates the version a release workflow was dispatched with. */
3+
import fs from 'node:fs'
4+
import path from 'node:path'
5+
import process from 'node:process'
6+
import { fileURLToPath } from 'node:url'
7+
8+
const root = fileURLToPath(new URL('..', import.meta.url))
9+
const requested = (process.argv[2] ?? '').trim()
10+
const { version: current } = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
11+
12+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Z.-]+)?$/i.test(requested)) {
13+
console.error(`[release] "${requested}" is not a version — expected 1.2.3 or 1.2.3-rc.1`)
14+
process.exit(1)
15+
}
16+
17+
function compare(left, right) {
18+
const a = left.split('-')[0].split('.').map(Number)
19+
const b = right.split('-')[0].split('.').map(Number)
20+
for (let index = 0; index < 3; index += 1) {
21+
if ((a[index] ?? 0) !== (b[index] ?? 0))
22+
return (a[index] ?? 0) > (b[index] ?? 0) ? 1 : -1
23+
}
24+
return left === right ? 0 : left.includes('-') ? -1 : 1
25+
}
26+
27+
if (compare(requested, current) <= 0) {
28+
console.error(`[release] "${requested}" is not greater than the current ${current}`)
29+
process.exit(1)
30+
}
31+
32+
console.log(`[release] ${current} -> ${requested}`)

‎scripts/release-notes.mjs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Prints the CHANGELOG.md section for one version — the body of the GitHub release.
4+
* changelogen generates the content (`npx changelogen --release -r <version>`), this
5+
* only lifts the section back out for `gh release create --notes-file`.
6+
*/
7+
import fs from 'node:fs'
8+
import path from 'node:path'
9+
import process from 'node:process'
10+
import { fileURLToPath } from 'node:url'
11+
12+
const version = (process.argv[2] ?? '').trim().replace(/^v/, '')
13+
if (version.length === 0) {
14+
console.error('usage: release-notes.mjs <version>')
15+
process.exit(1)
16+
}
17+
18+
const file = path.join(fileURLToPath(new URL('..', import.meta.url)), 'CHANGELOG.md')
19+
const lines = fs.readFileSync(file, 'utf8').split('\n')
20+
const heading = new RegExp(`^##\\s+v?${version.replace(/\./g, '\\.')}\\s*$`)
21+
// module scope: some repos enable e18e/prefer-static-regex, which flags a regex
22+
// literal that would be re-created on every callback invocation
23+
const nextHeading = /^##\s/
24+
const start = lines.findIndex(line => heading.test(line))
25+
26+
if (start === -1) {
27+
console.error(`[release] no "## v${version}" section in CHANGELOG.md`)
28+
process.exit(1)
29+
}
30+
31+
let end = lines.findIndex((line, index) => index > start && nextHeading.test(line))
32+
if (end === -1)
33+
end = lines.length
34+
35+
process.stdout.write(`${lines.slice(start + 1, end).join('\n').trim()}\n`)

0 commit comments

Comments
 (0)