-
Notifications
You must be signed in to change notification settings - Fork 0
214 lines (195 loc) · 9.89 KB
/
Copy pathrelease.yml
File metadata and controls
214 lines (195 loc) · 9.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
name: Release npm package
# npm trusted publishing is bound to this workflow by FILENAME (release.yml)
# and to the `npm-release` environment declared on the job below. Both strings
# are already recorded on npm's side as the trusted-publisher configuration for
# @hasna/conversations. They are NOT free choices: renaming either one silently
# de-authorises publishing, because the binding stops matching, and the failure
# surfaces as an auth error that never mentions the rename. Change them only
# together with `npm trust`.
#
# Provenance is generated automatically by npm under trusted publishing, with
# one condition that is easy to miss: npm does NOT generate provenance for
# PRIVATE repositories, even when the package itself is public. hasna/conversations is
# public (measured), so it holds here. If this repository is ever made private,
# provenance stops being produced and this workflow will not tell you.
on:
push:
tags:
- "npm/conversations/v*"
# A manual run exercises the repository gates without publishing. npm only
# exchanges the OIDC token during a publish or stage operation, so this does
# not claim to validate the npm-side trusted-publisher binding.
workflow_dispatch:
concurrency:
group: hasna-conversations-npm-release
cancel-in-progress: false
permissions:
contents: read
jobs:
publish:
# Never publish from a fork that inherited this workflow.
if: github.repository == 'hasna/conversations'
runs-on: ubuntu-latest
environment: npm-release
timeout-minutes: 30
permissions:
contents: read
# Mints the OIDC token npm exchanges for a short-lived publish
# credential. Without it there is no token at all and no fallback: this
# workflow deliberately carries no npm token of any kind.
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24.18.0"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: "1.3.14"
# Publishing runs through npm, not bun: bun publish has no OIDC trusted
# publishing support, so it cannot authenticate here at all.
- name: Verify npm supports trusted publishing
run: |
set -euo pipefail
have="$(npm --version)"
need="11.5.1"
if [ "$(printf '%s\n%s\n' "$need" "$have" | sort -V | head -n1)" != "$need" ]; then
echo "::error::npm ${have} is older than ${need}, so OIDC trusted publishing is unavailable. Raise node-version until its bundled npm meets the minimum."
exit 1
fi
echo "npm ${have} meets the ${need} minimum for trusted publishing"
- name: Install locked dependencies with release-age quarantine
run: bun install --frozen-lockfile --minimum-release-age 604800
- name: Bind the tag to the package version
id: version
run: |
set -euo pipefail
pkg_name="$(node -p "require('./package.json').name")"
pkg_version="$(node -p "require('./package.json').version")"
echo "name=${pkg_name}" >> "$GITHUB_OUTPUT"
echo "version=${pkg_version}" >> "$GITHUB_OUTPUT"
if [ "${GITHUB_REF_TYPE}" != "tag" ]; then
echo "manual run: ${pkg_name}@${pkg_version} from ${GITHUB_REF_NAME}"
exit 0
fi
case "${GITHUB_REF_NAME}" in
npm/conversations/v*)
tag_version="${GITHUB_REF_NAME#npm/conversations/v}" ;;
*)
echo "::error::tag ${GITHUB_REF_NAME} does not match a recognised release tag prefix"
exit 1 ;;
esac
if [ "${tag_version}" != "${pkg_version}" ]; then
echo "::error::tag ${GITHUB_REF_NAME} carries version ${tag_version} but package.json declares ${pkg_version}"
exit 1
fi
echo "tag ${GITHUB_REF_NAME} agrees with package.json ${pkg_version}"
# Tags are not protected by this repository's main-branch ruleset. Bind
# the release to reviewed history in the workflow itself so a tag on an
# unmerged branch cannot publish different code.
- name: Require the release commit on protected main
if: github.event_name == 'push'
run: |
set -euo pipefail
if ! git merge-base --is-ancestor "${GITHUB_SHA}" "refs/remotes/origin/main"; then
echo "::error::release commit ${GITHUB_SHA} is not contained in protected main"
exit 1
fi
echo "release commit ${GITHUB_SHA} is contained in protected main"
# npm versions are immutable, so a version that already exists can never
# be replaced by this run. Failing here names that plainly instead of
# letting the publish step report it after the whole suite has run.
- name: Reject an already published version
if: github.event_name == 'push'
run: |
set -euo pipefail
name="${{ steps.version.outputs.name }}"
version="${{ steps.version.outputs.version }}"
if npm view "${name}@${version}" version >/dev/null 2>&1; then
echo "::error::${name}@${version} is already published and npm versions are immutable. Bump the version."
exit 1
fi
echo "${name}@${version} is not yet published"
# `bun test` does not invoke tsc in this repository, so typecheck is its
# own step rather than something the suite implies.
- name: Typecheck
run: bun run typecheck
- name: Test
run: bun run test
- name: Build
run: bun run build
# dashboard/ is a SECOND dependency tree with its own lockfile, and no gate
# above touches it: ci.yml installs the root only, and `bun run build` does
# not enter dashboard/. Until this step existed, that tree's first and only
# install happened inside `npm publish` below, via prepublishOnly — after
# Typecheck, Test and Build had all gone green, so the reviewed thing and
# the published thing were separated by a dependency resolution that no
# gate could see.
#
# It ran unprotected in two distinct ways, both measured on bun 1.3.14:
# * unpinned — plain `bun install` on a drifted lockfile exits 0, silently
# re-resolves, and rewrites bun.lock ("Saved lockfile"). With
# --frozen-lockfile the same state exits 1.
# * unquarantined — the release-age quarantine on a workstation comes
# ENTIRELY from ~/.bunfig.toml, which does not exist on this runner.
# The identical install of a 5-day-old package exits 1 with a real HOME
# and exits 0 with an empty one. That is precisely why the root install
# above passes --minimum-release-age explicitly rather than relying on
# the environment, and the dashboard install needs the same treatment.
#
# Running it here, before the publish boundary, means the tree that ships is
# resolved inside the gated part of the workflow and is visible in these logs.
# prepublishOnly still rebuilds it, now under the same flags, so any other
# publish path is protected too.
#
# Note what each flag actually buys, because they are not interchangeable:
# --frozen-lockfile is load-bearing — it removes resolution from publish
# entirely. --minimum-release-age is defence in depth only; it is enforced
# at RESOLUTION time and does NOT re-validate versions already pinned in the
# lockfile (measured: a frozen install of a lock pinning a 5-day-old version
# exits 0). A too-new pin is caught by review of the lockfile diff, not here.
- name: Build dashboard with locked, quarantined dependencies
run: bun run build:dashboard
# `files` in package.json ships dashboard/dist/. npm silently OMITS a listed
# path that does not exist rather than failing, so a dashboard that never
# built would publish as a tarball quietly missing its web UI — an artefact
# defect with no error anywhere upstream. Turn that silence into a failure.
- name: Require the dashboard build output
run: |
set -euo pipefail
test -f dashboard/dist/index.html
echo "dashboard/dist/index.html present"
# No NODE_AUTH_TOKEN, and no token of any kind. npm detects the Actions
# OIDC environment and exchanges the id-token for a short-lived,
# publish-scoped credential. --provenance is passed explicitly: npm
# documents provenance as automatic under trusted publishing, but that
# has been reported not to hold in practice, and passing the flag is a
# no-op when it is already automatic.
- name: Publish to npm via OIDC trusted publishing
if: github.event_name == 'push'
run: npm publish --provenance --access public
- name: Verify the published version from the registry
if: github.event_name == 'push'
run: |
set -euo pipefail
name="${{ steps.version.outputs.name }}"
version="${{ steps.version.outputs.version }}"
for attempt in 1 2 3 4 5; do
if resolved="$(npm view "${name}@${version}" version 2>/dev/null)"; then
if [ "${resolved}" = "${version}" ]; then
echo "registry serves ${name}@${resolved}"
exit 0
fi
echo "::error::registry resolved ${name}@${version} to ${resolved}"
exit 1
fi
echo "attempt ${attempt}: ${name}@${version} not visible yet, waiting"
sleep 10
done
echo "::error::${name}@${version} did not become visible on the registry after publish"
exit 1