-
Notifications
You must be signed in to change notification settings - Fork 0
271 lines (245 loc) · 13.7 KB
/
Copy pathrelease.yml
File metadata and controls
271 lines (245 loc) · 13.7 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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
name: Release
# PR-based release flow (task_1788457898992, Aaron ruling "pipeline: pr-flow").
# Branch protection now requires a PR on main; @semantic-release/git's direct
# push was failing with GH006. Design doc:
# orgs/wyre/deliverables/forge/task_1788457898992_56617697/design-pr-flow-release.md
#
# ONE job, two modes, chosen by comparing package.json's version against the
# latest git tag at the start of every run:
# - no tag for the current version -> PUBLISH mode: a release PR was just
# merged (package.json is already bumped, nothing left but to ship it).
# Tag, npm publish, create the GitHub release.
# - tag already exists -> PREPARE mode: steady state. Check
# via semantic-release's --dry-run Node API whether new work warrants a
# release; if so, bump package.json/CHANGELOG.md and open/update a
# "chore(release): vX.Y.Z" PR. Publishes nothing.
#
# ORIGINAL DESIGN HAD THIS AS TWO SEPARATE WORKFLOWS, gated by
# `startsWith(github.event.head_commit.message, 'chore(release): ')`.
# REAL BUG (murph, caught in review before this ever merged): that string
# only appears in the push event for a rebase merge, or a squash merge whose
# title happens to match verbatim. This repo (like the others in scope) has
# all three merge methods enabled with GitHub's default "Merge pull request
# #N ..." commit-title format — the classic merge-commit button, still many
# people's default, would silently never match, so a release PR could merge
# clean and nothing would ever tag/publish/release. Same silent-failure
# shape as the GH006 bug this whole effort exists to fix. Worse: BOTH
# workflows shared the same flawed guard, so under the two-workflow design
# there was also a race — if the prepare workflow happened to run again on
# the same merge-commit push (guard failed to skip it) before the publish
# workflow tagged the version, it would re-analyze the same commits and
# could open a second, duplicate release PR with a duplicate CHANGELOG
# entry. Collapsing to one job with a file-state check (not a commit-message
# check) fixes both problems at once: there's no second workflow to race
# against, and the mode decision doesn't depend on which merge button
# someone clicked.
on:
push:
branches:
- main
# Serializes runs on main so two merges landing close together can't both
# reach mode-determination before either has tagged/published (murph,
# review catch): without this, a second run could check out a commit that
# still has no tag for the current package.json version and independently
# decide PUBLISH mode for the same version as an in-flight first run. Not
# cancel-in-progress — queues instead of dropping a run, since dropping a
# release attempt is worse than a short wait.
concurrency:
group: release-${{ github.ref }}
permissions:
contents: write
pull-requests: write
packages: write
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# CodeRabbit catch (CWE-250, task_1788457898992): the default
# persisted credential would stay live through npm ci/build/test
# below, so a compromised dependency's lifecycle script could
# misuse it to push. Each git network call downstream instead
# authenticates individually via an inline `-c http.extraheader`
# (never written to .git/config — see the second CodeRabbit catch,
# CWE-522, at "Determine mode" below).
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
registry-url: 'https://npm.pkg.github.com'
scope: '@wyre-ai'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run tests
run: npm test
# Each of the three publish artifacts (tag, npm package, GitHub release)
# is checked independently rather than inferring all-or-nothing status
# from the tag alone (CodeRabbit catch, review of this PR: a transient
# npm-publish or gh-release failure after the tag push would otherwise
# strand the version forever — the next run sees the tag, calls it
# steady state, and never retries the artifacts that actually failed).
# This makes a rerun after a partial failure resume exactly the
# missing steps instead of silently skipping them.
#
# Git auth note (CodeRabbit, CWE-250 then CWE-522, task_1788457898992):
# persist-credentials is false on checkout above, and this step's own
# `git fetch --tags` is the first git network call after the untrusted
# npm lifecycle. A first pass re-authenticated via `git remote
# set-url`, but that WRITES the token into .git/config where any later
# process in the job could read it back off disk. Using a `-c
# http.extraheader` on the git invocation itself instead scopes the
# credential to that one command's process environment -- nothing
# persists to a file. Every git network call in this workflow uses
# this same inline pattern; none set the remote URL.
#
# Also (CodeRabbit, CWE-319): every such call targets an explicit
# https://github.com/... URL rather than the `origin` remote name --
# if something upstream of this point ever rewrote origin's URL to an
# http:// scheme, using the remote name would silently send this
# Basic-auth header in cleartext. An explicit https:// URL can't be
# redirected that way.
- name: Determine mode (publish vs prepare)
id: mode
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
echo "::add-mask::$AUTH_HEADER"
git -c http.extraheader="$AUTH_HEADER" -c http.followRedirects=false fetch "https://github.com/${{ github.repository }}.git" --tags
VERSION=$(jq -r '.version' package.json)
PKG_NAME=$(jq -r '.name' package.json)
TAG_EXISTS=false
git rev-parse "v${VERSION}" >/dev/null 2>&1 && TAG_EXISTS=true
NPM_PUBLISHED=false
npm view "${PKG_NAME}@${VERSION}" version >/dev/null 2>&1 && NPM_PUBLISHED=true
RELEASE_EXISTS=false
gh release view "v${VERSION}" >/dev/null 2>&1 && RELEASE_EXISTS=true
if [ "$TAG_EXISTS" = true ] && [ "$NPM_PUBLISHED" = true ] && [ "$RELEASE_EXISTS" = true ]; then
echo "v${VERSION} fully published (tag+npm+release) — steady state."
echo "mode=prepare" >> "$GITHUB_OUTPUT"
else
echo "v${VERSION} not fully published (tag=${TAG_EXISTS} npm=${NPM_PUBLISHED} release=${RELEASE_EXISTS}) — publishing/resuming."
echo "mode=publish" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag_exists=${TAG_EXISTS}" >> "$GITHUB_OUTPUT"
echo "npm_published=${NPM_PUBLISHED}" >> "$GITHUB_OUTPUT"
echo "release_exists=${RELEASE_EXISTS}" >> "$GITHUB_OUTPUT"
fi
# --- PUBLISH mode: package.json was already bumped by a merged release PR ---
- name: "Publish: tag"
if: steps.mode.outputs.mode == 'publish' && steps.mode.outputs.tag_exists == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.mode.outputs.version }}"
AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
echo "::add-mask::$AUTH_HEADER"
git tag "v${VERSION}"
git -c http.extraheader="$AUTH_HEADER" -c http.followRedirects=false push "https://github.com/${{ github.repository }}.git" "v${VERSION}"
- name: "Publish: npm publish"
if: steps.mode.outputs.mode == 'publish' && steps.mode.outputs.npm_published == 'false'
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm publish
- name: "Publish: create GitHub release"
if: steps.mode.outputs.mode == 'publish' && steps.mode.outputs.release_exists == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.mode.outputs.version }}"
gh release create "v${VERSION}" --title "v${VERSION}" --generate-notes
# --- PREPARE mode: steady state, check for new releasable work ---
- name: "Prepare: compute next version and bump files"
if: steps.mode.outputs.mode == 'prepare'
id: prepare
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# @semantic-release/npm has npmPublish:true in .releaserc.json, so
# verifyConditions authenticates against the registry even under
# dryRun (CodeRabbit catch, first review round — the thread is
# marked outdated because release-prepare.yml was deleted in the
# single-workflow rewrite, not because the underlying auth gap was
# fixed there; it applies equally here).
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/prepare-release.mjs
# The default GITHUB_TOKEN can't create PRs on this org: WYRE-AI has
# "Allow GitHub Actions to create and approve pull requests" disabled
# org-wide (verified live, 2026-09-03: gh pr create with GITHUB_TOKEN
# failed with "GitHub Actions is not permitted to create or approve
# pull requests"; that's an org policy gating the github-actions[bot]
# identity specifically, not fixable per-repo — a repo-level attempt
# to loosen it 409s with "disabled by the organization"). A GitHub App
# installation token authenticates as a different identity and isn't
# subject to that restriction (verified live the same day: an
# App-token `gh pr create` against this exact repo succeeded, PR
# authored by app/wyre-agent-fleet). Used only for the `gh pr` calls
# below — `git push` authenticates separately with the default
# GITHUB_TOKEN via an inline http.extraheader (see PUSH_TOKEN below),
# since push was never the blocked operation.
- name: "Prepare: mint App token for PR creation"
if: steps.mode.outputs.mode == 'prepare' && steps.prepare.outputs.release_needed == 'true'
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: ${{ secrets.WYRE_APP_ID }}
private-key: ${{ secrets.WYRE_APP_PRIVATE_KEY }}
# Least-privilege (CodeRabbit + zizmor catch): with no
# `repositories` input, `owner` alone scopes the token to every
# repo the App installation covers, not just this one. And
# without an explicit `permission-*`, the token inherits the
# App's FULL installation permission set rather than just what
# this step actually uses. Pin both down.
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-pull-requests: write
# Root cause of a live failure on node-crewhu (task_1788457898992):
# `gh pr create`/`gh pr view` internally query
# `repository.defaultBranchRef`, which needs `contents: read` --
# `metadata: read` (bundled into every App token automatically) is
# not enough. Reproduced directly against the GitHub API: a token
# scoped to metadata+pull_requests only gets
# "Resource not accessible by integration" on that field; adding
# contents:read fixes it. Read-only, so still least-privilege.
permission-contents: read
- name: "Prepare: open or update release PR"
if: steps.mode.outputs.mode == 'prepare' && steps.prepare.outputs.release_needed == 'true'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
PUSH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.prepare.outputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -B release/next
git add package.json package-lock.json CHANGELOG.md 2>/dev/null || git add package.json CHANGELOG.md
git commit -m "chore(release): ${VERSION}
Prepared by scripts/prepare-release.mjs. Merging this PR (any
merge method) triggers this workflow's PUBLISH mode, which tags,
publishes to npm, and creates the GitHub release — nothing
publishes until this merges."
AUTH_HEADER="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$PUSH_TOKEN" | base64 | tr -d '\n')"
echo "::add-mask::$AUTH_HEADER"
git -c http.extraheader="$AUTH_HEADER" -c http.followRedirects=false push --force "https://github.com/${{ github.repository }}.git" release/next
if gh pr view release/next --json state --jq .state 2>/dev/null | grep -q OPEN; then
gh pr edit release/next --title "chore(release): ${VERSION}"
echo "Updated existing release PR."
else
gh pr create \
--base main \
--head release/next \
--title "chore(release): ${VERSION}" \
--body "Automated release PR. Merging this (any merge method) publishes ${VERSION} to npm and creates the GitHub release — see CHANGELOG.md in this diff for the notes."
echo "Opened new release PR."
fi