-
Notifications
You must be signed in to change notification settings - Fork 191
613 lines (574 loc) · 31.1 KB
/
Copy pathrelease.yml
File metadata and controls
613 lines (574 loc) · 31.1 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
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
name: Release
# Release workflow on inkeep/open-knowledge. Single npm publisher
# for BOTH auto-cadence beta releases AND on-demand stable promotions.
#
# Beta path: event-driven on `push` to legacy/main with a paths filter on
# `.changeset/**`. Copybara mirrors `public/open-knowledge/.changeset/*.md`,
# `pre.json`, and `config.json` from agents-private. When agents-private
# merges a PR with a changeset, Copybara batches it into a Project-import
# commit on legacy/main; that import push IS the release trigger. The
# workflow then runs `compute-next-beta.mjs` locally to derive baseVersion
# + release notes from the pending changeset pile (skip-gated by the
# previous beta's embedded consumed-set marker), resolves -beta.N inside
# the ok-release-cadence concurrency slot, and tags `github.sha` —
# guaranteed to be the import that contains the change because checkout
# happens AT the push event. This replaces the prior cross-repo
# repository_dispatch model, which suffered an off-by-one: the dispatch
# from agents-private fired before Copybara's mirror push landed, so
# legacy/main HEAD was still the previous import when checkout resolved
# `github.sha`, tagging the wrong commit (Linear PRD-6701).
#
# Three trigger types:
#
# 1. event_name=push (auto-cadence beta):
# Fires when Copybara mirrors a commit touching `.changeset/**`.
# Workflow:
# - Runs `compute-next-beta.mjs` to read `.changeset/*.md` + `pre.json`,
# recover the previous beta's consumed-set marker from
# `gh release view inkeep/open-knowledge`, compute the delta,
# and emit { baseVersion, maxBumpType, releaseNotes }. Empty delta
# (e.g., Copybara import that only touched non-changeset files
# matching the paths filter) → skip cleanly.
# - Resolves -beta.N counter from existing vX.Y.Z-beta.* tags
# (race-free inside the ok-release-cadence concurrency slot).
# - Overrides every fixed-group packages/*/package.json#version AND
# both packages/server/assets/skills/{project,discovery}/SKILL.md
# metadata.version to X.Y.Z-beta.N (transient jq, never committed).
# - Tags vX.Y.Z-beta.N at `github.sha` + creates a prerelease GitHub
# Release with the rendered notes (consumed-set marker embedded
# for the next cut to read).
# - Publishes @inkeep/open-knowledge@X.Y.Z-beta.N to npm with
# `--tag beta --provenance`.
# - Dispatches desktop-release.yml so the signed DMG +
# `beta-mac.yml` land on the prerelease.
#
# 2. event_name=workflow_dispatch (manual recovery):
# Same code path as the push trigger. Used after a transient flake
# (e.g., npm publish failed mid-run) to re-fire from the current
# main HEAD without waiting for a new mirror import.
#
# 3. repository_dispatch type=publish-stable (fired by promote-stable.yml
# on this same repo, once it has tagged vX.Y.Z + created the
# non-prerelease GitHub Release):
# client_payload: { ref: "vX.Y.Z", version: "X.Y.Z" }
# Workflow:
# - Checks out the stable tag (= the same SHA as the corresponding
# beta tag).
# - Overrides every fixed-group package.json#version to the clean
# X.Y.Z (the legacy commit at that SHA carries whatever
# agents-private mirrored, which is a `0.0.0` placeholder under
# the zero-commit-bump cadence).
# - Publishes @inkeep/open-knowledge@X.Y.Z to npm with
# `--tag latest --provenance`.
# promote-stable.yml separately fires its own `desktop-release`
# dispatch so the stable DMG + `latest-mac.yml` land on the stable
# Release.
#
# Why explicit `repository_dispatch` for the beta path here instead of
# relying on `release: published` to cascade: GitHub deliberately
# suppresses downstream workflow runs from GITHUB_TOKEN-authored
# events. This workflow's `gh release create` runs under GITHUB_TOKEN
# (the beta path stays on the workflow-default token), so a `release:
# published` event from it would NOT cascade to desktop-release.yml.
# `repository_dispatch` and `workflow_dispatch` are the documented
# exemption that cascade even when fired via GITHUB_TOKEN.
#
# `promote-stable.yml` reaches the same place via a different route:
# it uses an App-minted token (which DOES cascade) but creates the
# release as `--draft`, so `release: published` doesn't fire until
# desktop-release.yml later promotes the draft. Its dispatch comment
# documents that path.
#
# Why ONE workflow handles both beta and stable: npm Trusted Publishing
# only allows ONE trusted publisher per package. Centralizing all
# `pnpm exec changeset publish` calls here means there is exactly one
# trusted-publisher entry on npmjs.com (workflow=release.yml). Both
# event-driven betas and promote-stable route their npm publishes here.
#
# Concurrency group is shared with promote-stable.yml so beta cuts and
# stable promotions never race on the same legacy state.
on:
push:
branches: [main]
paths:
- ".changeset/**"
workflow_dispatch:
repository_dispatch:
types: [publish-stable]
permissions:
# contents: write — for beta path's git tag + Release creation.
# id-token: write — for npm OIDC. Used both for provenance attestation
# (the SLSA Build L3 attestation surfaced to consumers) AND for npm
# Trusted Publishing — the OIDC token's repo + workflow claims
# authenticate the publish without an NPM_TOKEN secret. The trusted-
# publisher entry is configured on npmjs.com against
# inkeep/open-knowledge + this workflow filename. Only ONE
# entry per package; both beta-cut and promote-stable route through
# here to share this entry.
# No packages: write — npm publishes go to npmjs.org, not GitHub
# Packages (ghcr.io / npm.pkg.github.com).
contents: write
id-token: write
concurrency:
group: ok-release-cadence
cancel-in-progress: false
jobs:
release:
name: Tag + publish (${{ github.event.action || github.event_name }})
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Link to triggering run
# Back-link: on the publish-stable path, promote-stable forwards its run
# URL in client_payload.dispatched_by. Blank on the beta push path.
env:
DISPATCHED_BY: ${{ github.event.client_payload.dispatched_by }}
run: |
set -euo pipefail
if [[ -n "${DISPATCHED_BY:-}" ]]; then
echo "::notice::Triggered by ${DISPATCHED_BY}"
{ echo "### Triggered by"; echo "- ${DISPATCHED_BY}"; } >> "$GITHUB_STEP_SUMMARY"
fi
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
# publish-stable: client_payload.ref carries the stable tag
# created by promote-stable.yml (= same SHA as the source
# beta tag).
# push / workflow_dispatch (beta path): no client_payload — fall
# through to github.sha, which IS the just-pushed Copybara
# import for `push` events (no race; this is the off-by-one
# fix from Linear PRD-6701).
ref: ${{ github.event.client_payload.ref || github.sha }}
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Get pnpm store directory
shell: bash
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_ENV"
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-ok-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-ok-pnpm-store-
- name: Setup Node for npm OIDC publish
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
# Pinned to the 11.x line, NOT npm@latest: npm 12.0.0's published
# tarball omits the top-level bundled `sigstore` module that
# libnpmpublish requires, so publishing with provenance dies with
# MODULE_NOT_FOUND the moment `latest` serves 12.x (broke the
# 0.28.1-beta.0/.1 cuts on 2026-07-08). Bump the pin only after
# verifying `npm publish` with NPM_CONFIG_PROVENANCE works on it.
- name: Ensure npm 11.5.1+ for OIDC + provenance
run: |
npm install -g npm@11
echo "npm $(npm --version)"
- name: Install
run: pnpm install --frozen-lockfile
# The cross-platform native-config `.node` binaries are produced by the
# standalone native-config-prebuild workflow (kept separate so the beta
# cadence stays pure-JS-fast). Stage the latest successful run's bundle
# into packages/native-config/ so the cli `build:native` step copies all
# eight platforms into dist/native/ before publish. The bundle must come
# from a prebuild run whose head commit is an ancestor of the release
# commit (provenance), so the native code we ship was built from source
# actually in this release rather than a later main commit racing the cut.
# Beta is resilient by design: a missing, failed, stale, or incomplete
# bundle warns and ships host-only, with non-host platforms on the
# non-destructive smol-toml fallback, so a beta is never worse than
# host-only and the cadence never blocks. Stable @latest is held higher:
# an incomplete set hard-fails the cut rather than silently shipping
# degraded native fidelity to the default install channel. Binaries are
# ABI-stable (napi6); the latest qualifying run's set is reused until
# native-config changes and a fresh prebuild run completes.
- name: Stage native-config prebuilt binaries
env:
GH_TOKEN: ${{ github.token }}
# Stable @latest cuts arrive as repository_dispatch (publish-stable);
# beta + manual recovery arrive as push/workflow_dispatch. Only stable
# hard-fails on an incomplete native-config set.
IS_STABLE: ${{ github.event_name == 'repository_dispatch' }}
run: |
# Record the staged-binary outcome in the job summary, then either
# warn-and-proceed (beta) or hard-fail (stable). A stable @latest
# publish shipping host-only native binaries is a fidelity regression,
# so it pages rather than ships silently degraded.
summarize() {
echo "Staged native-config binaries: $1" >> "$GITHUB_STEP_SUMMARY"
}
degrade_or_fail() {
# $1: human-readable reason, $2: staged-count label for the summary
summarize "$2 — $1"
if [ "$IS_STABLE" = "true" ]; then
echo "::error::$1; refusing to cut a stable @latest release with an incomplete native-config binary set"
exit 1
fi
echo "::warning::$1; CLI ships the host binary only (other platforms use the smol-toml fallback)"
exit 0
}
# Scope to runs on `main` triggered by `push` only. The prebuild also
# runs on `pull_request` (including from the public mirror's external
# PRs), so without this filter the latest-green run could be an
# unmerged branch whose Rust source is not what shipped to main — a
# supply-chain gap for native code executed on every CLI invocation.
run_id=$(gh run list --workflow=native-config-prebuild.yml --branch main --event push \
--status success --limit 1 --json databaseId --jq '.[0].databaseId // empty' 2>/dev/null || true)
if [ -z "$run_id" ]; then
degrade_or_fail "no successful native-config-prebuild run on main found" "0/8"
fi
# Provenance: the prebuild run's head commit must be an ancestor of the
# release commit, so the binaries staged were built from source that is
# actually in this release. fetch-depth:0 in checkout has full history.
head_sha=$(gh run view "$run_id" --json headSha -q .headSha 2>/dev/null || true)
if [ -z "$head_sha" ]; then
degrade_or_fail "could not resolve head commit of native-config-prebuild run $run_id" "0/8"
fi
if ! git merge-base --is-ancestor "$head_sha" HEAD 2>/dev/null; then
degrade_or_fail "native-config-prebuild run $run_id (commit $head_sha) is not an ancestor of the release commit" "0/8"
fi
if ! gh run download "$run_id" --name native-config-bindings-all --dir /tmp/nc-bindings; then
degrade_or_fail "could not download native-config-bindings-all from run $run_id" "0/8"
fi
find /tmp/nc-bindings -name '*.node' -exec cp -f {} packages/native-config/ \;
count=$(find packages/native-config -maxdepth 1 -name '*.node' | wc -l | tr -d ' ')
echo "staged $count native-config prebuilt binaries from run $run_id (commit $head_sha)"
if [ "$count" -lt 8 ]; then
degrade_or_fail "staged only $count/8 native-config binaries" "$count/8"
fi
summarize "$count/8 from run $run_id (commit $head_sha)"
# `pnpm run build` builds the native-config addon's host `.node` via
# `napi build` (turbo dep of the cli build), which needs a Rust toolchain.
# Pin it explicitly rather than relying on whatever `ubuntu-latest`
# preinstalls, so an image change can't silently break the whole publish at
# the unguarded cargo invocation. Mirrors native-config-prebuild.yml.
- name: Setup Rust toolchain for the native-config build
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
with:
toolchain: stable
# `pnpm exec changeset publish` invokes `npm publish` per package, which
# in turn fires the cli's `prepublishOnly` (-> `pnpm run build`). The
# cli's own build chain includes `cp -r ../app/dist dist/public`
# which silently fails when the app hasn't been built first. Run a
# top-level `pnpm run build` (turbo orchestrates the dep order:
# app -> cli) so every package's dist/ exists before the publish
# step runs.
- name: Build packages
run: pnpm run build
# --- BETA PATH (push + workflow_dispatch) ---
# Compute baseVersion + maxBumpType + rendered release notes locally
# from the changeset pile that Copybara mirrored into the just-pushed
# commit. compute-next-beta.mjs reads `.changeset/*.md`, `pre.json`,
# and `gh release view <prev-beta-tag>` for the embedded consumed-set
# marker; emits `{skip: true, reason}` when no new changesets exist
# since the last beta (e.g., a Copybara import that touched
# `.changeset/config.json` but no `.md` files, or main-reset
# consolidation cycles). The script MUTATES the working tree
# (pre.json + CHANGELOG.md + package.json + deletes .changeset/*.md);
# the final `Restore working tree` step cleans up.
- name: Compute next beta base version + render release notes
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
id: compute-beta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
OUTPUT=$(node scripts/compute-next-beta.mjs)
echo "Compute output:"
echo "$OUTPUT" | jq .
SKIP=$(echo "$OUTPUT" | jq -r '.skip // false')
if [ "$SKIP" = "true" ]; then
REASON=$(echo "$OUTPUT" | jq -r .reason)
echo "::notice::Skipping beta cut: $REASON"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
BASE=$(echo "$OUTPUT" | jq -r .baseVersion)
# Defer (don't fail) while the anchor is catching up to a fresh stable.
# Right after a stable promotion, pre.json's anchor still trails the
# just-shipped stable until main-reset advances it (a cross-repo PR +
# Copybara round-trip — minutes). Any mirror push landing in that window
# computes BASE == the fresh stable. Cutting it would regress `@latest`;
# hard-failing pages on a transient, self-healing condition. Skip this
# cut instead — a later push re-cuts automatically once the anchor leads.
# A base strictly BEHIND the stable is NOT this transient window; that is
# left to the Guard step below to hard-fail. fetch-depth:0 has all tags.
LATEST_STABLE_TAG=$(git tag --list 'v*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)
if [[ -n "$LATEST_STABLE_TAG" && "$BASE" == "${LATEST_STABLE_TAG#v}" ]]; then
echo "::warning::Beta base ${BASE} equals the latest stable ${LATEST_STABLE_TAG#v} — pre.json's anchor has not been advanced by main-reset yet. Deferring this beta cut; it re-cuts automatically once the anchor leads."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
BUMP=$(echo "$OUTPUT" | jq -r .maxBumpType)
COUNT=$(echo "$OUTPUT" | jq -r .pendingCount)
NOTES_B64=$(echo "$OUTPUT" | jq -r .releaseNotes | base64 | tr -d '\n')
# `run_beta` is the single signal downstream beta steps gate on.
# Absent (publish-stable path didn't run this step) or `false`
# (skip branch above) both evaluate as not-beta downstream.
{
echo "skip=false"
echo "run_beta=true"
echo "base_version=$BASE"
echo "max_bump_type=$BUMP"
echo "pending_count=$COUNT"
echo "notes_b64=$NOTES_B64"
} >> "$GITHUB_OUTPUT"
echo "Computed: base=$BASE bump=$BUMP pending=$COUNT"
# Anchor-drift backstop: the computed beta base must not be BEHIND the
# latest published stable. The common post-promotion case (base EQUAL to
# the fresh stable, anchor not yet advanced by main-reset) is now handled
# upstream in compute-beta as a deferred skip, so run_beta is only 'true'
# here when base != stable. A base strictly BEHIND stable is genuine anchor
# drift — not the transient window — and would cut a `-beta.N` that is
# semver-BEHIND `@latest`; fail loud with the recovery action. Uses the
# latest stable tag (no network) — fetch-depth:0 has all tags locally.
- name: Guard - beta base must lead the latest stable
if: steps.compute-beta.outputs.run_beta == 'true'
env:
BASE_VERSION: ${{ steps.compute-beta.outputs.base_version }}
run: |
set -euo pipefail
LATEST_STABLE_TAG=$(git tag --list 'v*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n1 || true)
if [[ -z "$LATEST_STABLE_TAG" ]]; then
echo "No stable tag yet; skipping lead check (bootstrap)."
exit 0
fi
STABLE="${LATEST_STABLE_TAG#v}"
TOP=$(printf '%s\n%s\n' "$BASE_VERSION" "$STABLE" | sort -V | tail -n1)
if [[ "$TOP" != "$BASE_VERSION" ]]; then
echo "::error::Computed beta base ${BASE_VERSION} is BEHIND the latest stable ${STABLE} — genuine anchor drift, not the transient post-promotion window (base==stable is deferred upstream)."
echo "::error::Run public-open-knowledge-main-reset.yml with stable_version=${STABLE} on agents-private, then re-cut."
exit 1
fi
echo "Beta base ${BASE_VERSION} leads latest stable ${STABLE}."
# Resolve the -beta.N counter inside this concurrency slot so
# concurrent push events (e.g., two rapid Copybara imports that each
# carry a changeset) see different N's and never collide on tag
# creation. The concurrency group serializes runs against the same
# legacy state, so the max-N scan is race-free.
- name: Resolve -beta.N counter
if: steps.compute-beta.outputs.run_beta == 'true'
id: resolve-beta
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BASE_VERSION: ${{ steps.compute-beta.outputs.base_version }}
run: |
set -euo pipefail
if [[ -z "${BASE_VERSION:-}" ]]; then
echo "::error::compute-beta did not emit base_version"
exit 1
fi
if [[ ! "$BASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::compute-beta base_version '${BASE_VERSION}' is not bare semver (X.Y.Z)"
exit 1
fi
# Query existing v<BASE_VERSION>-beta.N tags on this repo and
# find the max N. `git tag --list` only sees local tags; we
# fetched them in the checkout step (fetch-depth: 0 pulls all
# refs by default for actions/checkout@v6). Cross-check via
# `gh api` for tags created by parallel runs that pushed to
# origin since our checkout finished — the actual collision
# window is between this step and the `git push origin TAG`
# step below, which the concurrency group eliminates.
MAX_N=-1
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
if [[ "$tag" =~ ^v${BASE_VERSION//./\\.}-beta\.([0-9]+)$ ]]; then
n="${BASH_REMATCH[1]}"
if (( n > MAX_N )); then MAX_N="$n"; fi
fi
done < <(git tag --list "v${BASE_VERSION}-beta.*")
NEXT_N=$((MAX_N + 1))
VERSION="${BASE_VERSION}-beta.${NEXT_N}"
TAG="v${VERSION}"
echo "Existing max -beta.N for base ${BASE_VERSION}: ${MAX_N} -> emitting ${TAG}"
{
echo "version=$VERSION"
echo "tag=$TAG"
} >> "$GITHUB_OUTPUT"
- name: Override fixed-group versions to X.Y.Z-beta.N
if: steps.compute-beta.outputs.run_beta == 'true'
env:
VERSION: ${{ steps.resolve-beta.outputs.version }}
run: |
set -euo pipefail
# Same primitive as the publish-stable path: jq edits the
# version field directly, never committed. The change lives
# only for this workflow run and is discarded with the runner.
# `npm version` would walk workspace deps and reject pnpm's
# `workspace:*` protocol with EUNSUPPORTEDPROTOCOL — jq sidesteps.
for pkg in cli core server app desktop; do
f="packages/${pkg}/package.json"
jq --arg v "$VERSION" '.version=$v' "$f" > "${f}.tmp"
mv "${f}.tmp" "$f"
done
# Both skill bundles' metadata.version "tracks
# @inkeep/open-knowledge-server package version" — the build-time
# invariant test in packages/server/src/skill-state.test.ts asserts
# they match. Mutate in place (sed: same transient-only model as jq
# above), once per bundle.
for skill in packages/server/assets/skills/project/SKILL.md \
packages/server/assets/skills/discovery/SKILL.md; do
sed -i'.bak' -E "s/^([[:space:]]+version:[[:space:]]+\")[^\"]+(\".*)$/\1${VERSION}\2/" "$skill"
rm -f "${skill}.bak"
done
echo "All fixed-group package.json files + both skill bundles' metadata.version now at ${VERSION} (in-memory only)."
- name: Tag + create prerelease GitHub Release
if: steps.compute-beta.outputs.run_beta == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.resolve-beta.outputs.tag }}
NOTES_B64: ${{ steps.compute-beta.outputs.notes_b64 }}
run: |
set -euo pipefail
# Idempotency: a workflow re-fire (manual recovery from a flaky
# npm publish, or workflow_dispatch re-run) should not explode
# on `git tag` already exists. Within the concurrency slot, a
# tag-exists state only arises on retry.
if git rev-parse "refs/tags/${TAG}" >/dev/null 2>&1; then
echo "Tag ${TAG} already exists locally; skipping git tag step."
else
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag "$TAG"
git push origin "$TAG"
fi
BODY_FILE=$(mktemp)
if [[ -n "${NOTES_B64:-}" ]]; then
echo "$NOTES_B64" | base64 --decode > "$BODY_FILE"
else
printf 'Auto-cadence beta release.\n' > "$BODY_FILE"
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release ${TAG} already exists; updating notes."
gh release edit "$TAG" --notes-file "$BODY_FILE"
else
# Create as draft so the release is invisible in the .atom feed
# until desktop-release.yml uploads beta-mac.yml AND promotes
# draft → published. This closes the publish-window race that
# surfaced misleading latest-mac.yml 404 errors in user logs
# during the gap between release creation and DMG upload
# (typical 5-10 min; up to ~45 min if notary or sign is slow —
# desktop-release.yml's job timeout-minutes is 45). Channel-
# mirror invariant: see
# packages/desktop/src/main/auto-updater.ts:336-356.
gh release create "$TAG" \
--draft \
--prerelease \
--title "$TAG" \
--notes-file "$BODY_FILE"
fi
# The `gh release create` above runs as GITHUB_TOKEN, and GitHub
# deliberately suppresses cascading workflow runs from
# GITHUB_TOKEN-authored events to prevent recursion — so
# `release: published` does NOT fire desktop-release.yml on its
# own. The exemption to that rule is `repository_dispatch` (and
# `workflow_dispatch`), which DO cascade even when fired via
# GITHUB_TOKEN. Same-repo dispatch needs no PAT or App token —
# the default `contents: write` token is enough.
- name: Trigger desktop-release.yml to build + upload DMG
if: steps.compute-beta.outputs.run_beta == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.resolve-beta.outputs.tag }}
run: |
set -euo pipefail
payload=$(jq -nc \
--arg tag "$TAG" \
--arg ref "$TAG" \
'{event_type: "desktop-release", client_payload: {release_tag: $tag, ref: $ref}}')
gh api -X POST "repos/${GITHUB_REPOSITORY}/dispatches" --input - <<<"$payload"
echo "Dispatched desktop-release for ${TAG}; desktop-release.yml will build the signed DMG and upload it + beta-mac.yml to the prerelease."
# --- STABLE DISPATCH PATH ---
- name: Validate stable_version + override package.json (publish-stable)
if: github.event.action == 'publish-stable'
env:
STABLE_VERSION: ${{ github.event.client_payload.version }}
run: |
set -euo pipefail
if [[ -z "${STABLE_VERSION:-}" ]]; then
echo "::error::publish-stable dispatch missing client_payload.version"
exit 1
fi
if [[ ! "$STABLE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::publish-stable client_payload.version '${STABLE_VERSION}' is not bare semver (X.Y.Z)"
exit 1
fi
# Same transient-jq primitive as the beta path: edit
# version fields in place, never commit. The legacy commit at
# the source beta SHA carries whatever package.json version
# agents-private mirrored (under the zero-commit-bump cadence,
# that's the placeholder version on main); we override to the
# clean X.Y.Z just for this publish.
for pkg in cli core server app desktop; do
f="packages/${pkg}/package.json"
jq --arg v "$STABLE_VERSION" '.version=$v' "$f" > "${f}.tmp"
mv "${f}.tmp" "$f"
done
# Match the beta path's skill-bundle sync so the published stable
# carries SKILL.md bundles whose metadata.version reads X.Y.Z, not
# whatever -beta.N was last stamped (or, in the steady state,
# the placeholder).
for skill in packages/server/assets/skills/project/SKILL.md \
packages/server/assets/skills/discovery/SKILL.md; do
sed -i'.bak' -E "s/^([[:space:]]+version:[[:space:]]+\")[^\"]+(\".*)$/\1${STABLE_VERSION}\2/" "$skill"
rm -f "${skill}.bak"
done
echo "All fixed-group package.json files + both skill bundles' metadata.version now at ${STABLE_VERSION} (in-memory only)."
# --- SHARED PUBLISH STEP ---
- name: Publish to npm via Trusted Publishing
if: github.event.action == 'publish-stable' || steps.compute-beta.outputs.run_beta == 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
ACTION: ${{ github.event.action }}
BETA_VERSION: ${{ steps.resolve-beta.outputs.version }}
STABLE_VERSION: ${{ github.event.client_payload.version }}
run: |
set -euo pipefail
# Auth via npm Trusted Publishing (OIDC). The id-token: write
# permission lets npm CLI 11.5.1+ exchange the GitHub Actions
# OIDC token for a short-lived npm token automatically — no
# NPM_TOKEN secret, no ~/.npmrc auth write. The trusted-
# publisher entry on npmjs.com matches the OIDC token's repo
# (inkeep/open-knowledge) + workflow filename
# (release.yml) claims. Provenance attestation also uses OIDC
# (NPM_CONFIG_PROVENANCE). See
# https://docs.npmjs.com/trusted-publishers.
if [[ "$ACTION" == "publish-stable" ]]; then
TAG=latest
VERSION="$STABLE_VERSION"
else
TAG=beta
VERSION="$BETA_VERSION"
fi
# Remove pre.json from the working tree before publish so
# `changeset publish` always runs as a "normal publish" — same
# design intent that held when Copybara excluded `.changeset/`
# entirely (pre.json now mirrors so that `compute-next-beta.mjs`
# can read `initialVersions` for the cycle anchor, but pre-mode
# publish semantics aren't well-documented and we don't want
# to depend on them). The runner discards working-tree state
# after the job, and the `Restore working tree` step at the
# end runs `git restore .` defensively.
rm -f .changeset/pre.json
# Changesets reads .changeset/config.json from the repo root
# and publishes every package in the fixed group. On the beta
# path the jq override has set every fixed-group
# package.json#version to X.Y.Z-beta.N (a pre-release semver);
# `npm publish --tag beta` accepts that as-is. On the stable
# path the override is X.Y.Z. Only @inkeep/open-knowledge
# actually reaches the registry; the four private:true packages
# are skipped by `changeset publish`.
echo "Publishing with --tag ${TAG} (VERSION=${VERSION})"
pnpm exec changeset publish --tag "$TAG"
- name: Restore working tree (defensive)
# Both paths mutate package.json + SKILL.md transiently via jq/sed.
# The beta path additionally mutates pre.json + CHANGELOG.md and
# deletes consumed .changeset/*.md files via compute-next-beta.mjs.
# `git restore .` reverses all of these, including deletions.
if: always()
run: git restore . || true