Repository navigation
450 lines (440 loc) · 24.1 KB
/
Copy pathrelease.yml
File metadata and controls
450 lines (440 loc) · 24.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
# Release automation (M7-P1, ADR-007). Two-phase by design
# (docs/RELEASE.md, distilled from the M7 booklet in git history): a
# manual workflow_dispatch builds the five roster targets (plan v2.29
# step 10, O70 — x86_64 and aarch64 for Linux and macOS, x86_64
# Windows; update::version::TARGETS) through one reusable workflow,
# build-target.yml, which ci.yml's weekly release-rehearsal runs too,
# and uploads DRAFT assets + SHA256SUMS; the measured hashes then land
# in plugin/bin/manifest.env as a commit; only then the v* tag is cut,
# whose job re-downloads the SAME draft assets (no rebuild — Rust and
# GHC builds are not bit-reproducible across runs, so a rebuild would
# invalidate honest pins), checks that the draft was built from the
# tag's own sources and carries real notes, verifies every pin,
# publishes, fast-forwards the `release` branch the marketplace channel
# reads (O82), and — each behind a secret the repository owner chooses
# to set — publishes the crate (O75), pushes the Homebrew formula and
# opens the winget pull request (O71). macOS 10x billing (D2-8) is fine
# here: releases are rare and explicitly human-triggered.
name: release
on:
workflow_dispatch:
inputs:
version:
description: "Version to draft (e.g. 0.1.0, no leading v)"
required: true
push:
tags: ["v*"]
# Least privilege per job (2026-08-19 review): the workflow-level
# write handed a release-editing token to the BUILD job too — which
# compiles third-party build.rs/Setup.hs and needs no write at all.
# Only the jobs that touch the release carry it.
permissions:
contents: read
# The draft's placeholder notes, spelled once: the draft job writes them
# and the publish gate refuses a release still carrying them (O85) — the
# real notes are written on the draft BEFORE the tag (docs/RELEASE.md §2).
env:
DRAFT_NOTES_MARKER: "Draft build phase"
# One release operation at a time, repository-wide: the group is a
# CONSTANT. Keyed on the ref, a dispatch (refs/heads/main) and a tag
# push (refs/tags/vX) fell into two groups and could touch the same
# release concurrently — the very pair this line exists to serialise
# (O86). Queued, never cancelled — a half-finished publish is worse
# than a late one.
concurrency:
group: release
cancel-in-progress: false
jobs:
# The roster, one row per key — held to update::version::TARGETS by
# tests/it/release_roster.rs, so a sixth target lands here, in the
# verify loop below, in the manifest and in scripts/roster.js, or in
# none of them.
build:
if: github.event_name == 'workflow_dispatch'
strategy:
fail-fast: false
matrix:
include:
- { os: windows-latest, key: x86_64-windows, triple: x86_64-pc-windows-msvc, ext: ".exe" }
- { os: ubuntu-22.04, key: x86_64-linux, triple: x86_64-unknown-linux-gnu, ext: "" } # glibc 2.35 floor: built on 24.04 the binaries asked for 2.39, so Debian 12 (2.36) and Ubuntu 22.04 could not run them
- { os: macos-15, key: aarch64-macos, triple: aarch64-apple-darwin, ext: "" } # macos-15 for GHC: see ci.yml build-macos
- { os: macos-15-intel, key: x86_64-macos, triple: x86_64-apple-darwin, ext: "" }
- { os: ubuntu-22.04-arm, key: aarch64-linux, triple: aarch64-unknown-linux-gnu, ext: "" } # glibc 2.35 floor, as the x86_64-linux row
uses: ./.github/workflows/build-target.yml
with:
os: ${{ matrix.os }}
key: ${{ matrix.key }}
triple: ${{ matrix.triple }}
ext: ${{ matrix.ext }}
version: ${{ inputs.version }}
upload: true
draft:
if: github.event_name == 'workflow_dispatch'
needs: build
runs-on: ubuntu-latest
permissions:
contents: write # gh release create/upload
env:
GH_TOKEN: ${{ github.token }}
RELEASE_VERSION: ${{ inputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: dist-*
merge-multiple: true
path: dist
- name: Checksums + draft release
shell: bash
run: |
VER="$RELEASE_VERSION"
cd dist
sha256sum -- * > SHA256SUMS
cat SHA256SUMS
cd ..
if gh release view "v$VER" >/dev/null 2>&1; then
# only a DRAFT may be re-uploaded. `gh release view`
# succeeds for published releases too, so a re-dispatch
# replaced shipped bytes with fresh non-reproducible ones
# and regenerated SHA256SUMS to match — invalidating every
# pin already in users' hands.
[ "$(gh release view "v$VER" --json isDraft -q .isDraft)" = "true" ] || {
echo "v$VER is already published — refusing to clobber its assets"; exit 1; }
gh release upload "v$VER" dist/* --clobber
# the assets now come from THIS commit, and the provenance
# the publish gate reads (verify-publish) must move with them
gh release edit "v$VER" --target "$GITHUB_SHA"
else
# --target records the commit these assets were built from
# (O84): the publish gate compares its source trees with the
# tag commit's, so a tag can never ship binaries built from
# sources it does not carry
gh release create "v$VER" --draft --target "$GITHUB_SHA" \
--title "CodeEraser $VER" \
--notes "$DRAFT_NOTES_MARKER (M7-P1). Unsigned binaries — verify SHA256SUMS; pins land in plugin/bin/manifest.env before the tag publishes this release." \
dist/*
fi
verify-publish:
# The EVENT, not the ref shape. A workflow_dispatch aimed at a tag
# made all three job conditions true at once — build, draft and
# publish in one run, against the two-phase order this file's
# header commits to. The tag filter already lives in `on:`.
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write # gh release download/edit
checks: read # the CI gate below
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# the provenance step below reads the draft's build commit,
# an ancestor on main: a shallow clone would not hold it
fetch-depth: 0
# Publication was gated on nothing but byte integrity: this job
# and ci.yml fire on the SAME tag push as independent workflows,
# and a download + a few hashes finishes long before a
# multi-platform build+test matrix can report — so a fully red CI
# on the tagged commit published anyway. Wait for this commit's
# checks, and refuse on any conclusion that is not success.
#
# SKIPPED is not success (K step 10). The old escape waved every
# skipped or neutral check through, so a job accidentally
# conditioned away — the failure mode an `if:` typo produces —
# published with nothing behind it. On a tag a few checks skip BY
# DESIGN and are excused by their REAL surfaced names (the v1.2.0
# night lesson: a matrix job skipped by its `if:` collapses to ONE
# check named plainly `build`, never the platform-expanded names
# this list first imagined): this workflow's own dispatch-phase
# `build` and `draft`, and ci.yml's schedule/dispatch-only jobs —
# the network legs and the release rehearsal, which never run on
# a push. tests/it/release_roster.rs holds SKIPPED_OK to ci.yml's
# `if:` lines, so a new schedule-only job cannot silently refuse
# the next publish. Only the skipped conclusion is excused: a
# failed run of any name refuses. Any other skipped or neutral
# check refuses with its name: the failure direction is a loud
# non-publish, never a silent publish.
#
# ABSENCE was still success (plan v2.29 step 12, item 12b). Every
# question this loop asked was about the checks it found: nothing
# pending, nothing failed. A commit whose checks have not been
# created yet answers with an EMPTY list, and an empty list has
# nothing pending and nothing failed — "all checks green" on zero
# evidence, which is the same publish the whole step exists to
# prevent. So the legs that actually build and test the tagged
# tree are REQUIRED by name and must have concluded success: the
# ci.yml `build` matrix (surfaced platform-expanded, unlike a
# skipped matrix) and `build-macos`, which carries no `if:` and so
# runs on every push. Verified against run 34040780731 / commit
# 05bb595: `build (ubuntu-latest)`, `build (windows-latest)`,
# `build-macos`. Absent is PENDING, not refused — they may simply
# not have been created yet — so the wait runs its course and the
# timeout below names them. tests/it/release_roster.rs holds this
# list to ci.yml's own job and matrix spellings.
- name: CI must be green on this commit
shell: bash
run: |
SKIPPED_OK="build draft starter-https setup-wiring release-rehearsal packaging-live"
ok=$(printf '%s\n' $SKIPPED_OK | jq -R . | jq -sc .)
# `|`-separated: these names carry spaces, so a word list cannot
# hold them (and the shell would split them silently)
REQUIRED="build (ubuntu-latest)|build (windows-latest)|build-macos"
req=$(printf '%s\n' "$REQUIRED" | tr '|' '\n' | jq -R . | jq -sc .)
# This run's own UNFINISHED jobs cannot gate this run: this job
# is in_progress while it polls, and publish-crate /
# homebrew-tap / winget-pr sit QUEUED on `needs:` it — waiting
# for them is waiting for ourselves until the ceiling expires
# on a release that was fine. Identity is the check SUITE, so
# a job added below later is covered without editing anything;
# this run's COMPLETED checks (the skipped dispatch phase) stay
# in the list and are excused by name like any other.
self=$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .check_suite_id)
case "$self" in
''|*[!0-9]*) echo "cannot read this run's check suite id: '$self'"; exit 1 ;;
esac
# Two hours, not thirty minutes: this loop waits on a macOS
# runner GitHub schedules at its own pace, and v1.3.1's first
# tag run burned the whole budget with build-macos still
# QUEUED — never started, 30.6 min after the tag push, both
# push legs green for the last 17.7 of them. The ceiling has
# to bound the queue, not the build.
for _ in $(seq 1 240); do
# --paginate: re-runs accumulate check runs on a commit and
# one page of 100 is not the list — a required leg on page
# two reads exactly like one that never ran. gh applies --jq
# per page and concatenates (measured with per_page=3 on
# commit 05bb595: 3 pages, 7 objects), so the stream is
# slurped back into one array here.
runs=$(gh api --paginate \
"repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/check-runs?per_page=100" \
--jq '.check_runs[]' \
| jq -sc '[.[] | select(.status == "completed" or (.check_suite.id // 0) != '"$self"')]')
pending=$(echo "$runs" | jq '[.[] | select(.status != "completed")] | length')
bad=$(echo "$runs" | jq -r --argjson ok "$ok" '[.[]
| select(.conclusion != null and .conclusion != "success")
| select((.conclusion == "skipped" and (.name as $n | $ok | index($n) != null)) | not)]
| map(.name + "=" + .conclusion) | join(", ")')
[ -z "$bad" ] || { echo "refusing to publish: $bad"; exit 1; }
absent=$(echo "$runs" | jq -r --argjson req "$req" '[$req[] as $n
| select([.[] | select(.name == $n and .conclusion == "success")] | length == 0)
| $n] | join(", ")')
if [ "$pending" = "0" ] && [ -z "$absent" ]; then
echo "all checks green"; exit 0
fi
# name them: a bare count cannot say which leg is missing,
# and the refusal below is the only record anyone reads
names=$(echo "$runs" | jq -r '[.[] | select(.status != "completed")
| .name + "(" + .status + ")"] | join(", ")')
echo "waiting: $pending unfinished [${names:-none}]; required not green [${absent:-none}]"
sleep 30
done
echo "refusing to publish: checks did not settle in 2 h: unfinished [${names:-none}]; required not green [${absent:-none}]"; exit 1
# Provenance (O84): the binaries were built by the dispatch run at
# an earlier commit and the tag names another; nothing tied the
# two, so a tag could publish assets built from sources it does
# not carry. The draft job records its build commit as the
# release target; here every tree the binaries are built from
# must be identical at both commits. The pin commit between them
# touches plugin/bin/manifest.env, contracts/docs-facts.json and
# the packaging/ projections only, so the roster below is exactly
# what may NOT differ.
#
# "Built from" is wider than "sources": the first roster listed
# the directories a reader thinks of as the program and missed
# inputs that decide the bytes just as much — rust-toolchain.toml
# picks the compiler both workspaces are built with, and
# build-target.yml is the recipe the draft actually ran. Each can
# differ across the two commits with every tree above identical.
#
# Notes (O85): the draft ships with placeholder notes and nothing
# refused to publish them — releases went out reading "Draft
# build phase" until a human edited after the fact. The notes
# are written on the draft BEFORE the tag (docs/RELEASE.md §2);
# a release still carrying the placeholder, or none, is refused.
- name: Draft provenance and release notes
shell: bash
run: |
TAG="${GITHUB_REF_NAME}"
build=$(gh release view "$TAG" --json targetCommitish -q .targetCommitish)
case "$build" in
*[!0-9a-f]*|"") echo "release target is not a commit sha: '$build' — the draft must be created with --target"; exit 1 ;;
esac
[ "${#build}" -eq 40 ] || { echo "release target is not a full sha: $build"; exit 1; }
git cat-file -e "$build^{commit}" || { echo "build commit $build is not in this clone"; exit 1; }
for tree in cli/src cli/Cargo.toml cli/Cargo.lock core/app core/ce-core.cabal \
core/cabal.project core/cabal.project.freeze gui/src-tauri gui/ui \
rust-toolchain.toml \
.github/workflows/build-target.yml; do
at_build=$(git rev-parse "$build:$tree")
at_tag=$(git rev-parse "$GITHUB_SHA:$tree")
[ "$at_build" = "$at_tag" ] || {
echo "refusing to publish: $tree differs between the build commit $build and the tag commit $GITHUB_SHA"; exit 1; }
echo "provenance ok: $tree"
done
body=$(gh release view "$TAG" --json body -q .body)
[ -n "$body" ] || { echo "refusing to publish: the release has no notes"; exit 1; }
case "$body" in
*"$DRAFT_NOTES_MARKER"*) echo "refusing to publish: the notes are still the draft placeholder — write them on the draft first (docs/RELEASE.md §2)"; exit 1 ;;
esac
echo "notes ok: $(printf '%s\n' "$body" | wc -l) line(s)"
# The asset roster is DERIVED from the target list the way
# scripts/roster.js derives it — one ce, one ce-core, one GUI
# bundle per key, the bundle kind and the binary suffix following
# the key's os half — and the target list is held to
# update::version::TARGETS by tests/it/release_roster.rs. An
# explicit roster, not a wildcard: `--pattern '*'` made an
# unexpected asset a silent passenger, and `sha256sum -c` ignores
# files the list does not name.
- name: Verify draft assets against manifest pins, then publish
shell: bash
run: |
TAG="${GITHUB_REF_NAME}"
VER="${TAG#v}"
. plugin/bin/manifest.env
[ "$CE_MANIFEST_VERSION" = "$VER" ] || {
echo "manifest version $CE_MANIFEST_VERSION != tag $VER"; exit 1; }
# the base URL embeds the tag too, and nothing checked it —
# a stale one publishes green and silently turns every
# pinned download into a 404, degrading the whole plugin
# to "PATH or nothing" (docs/RELEASE.md named this failure
# as a hand-maintained step; now it is a gate)
case "$CE_BASE_URL" in
*/download/"$TAG") ;;
*) echo "base url $CE_BASE_URL does not end in /download/$TAG"; exit 1 ;;
esac
TARGETS="x86_64-windows x86_64-linux aarch64-macos x86_64-macos aarch64-linux"
bundle() { # $1=key -> "<asset suffix> <manifest tail>"
case "$1" in
*-windows) echo "-setup.exe SETUP" ;;
*-linux) echo ".AppImage APPIMAGE" ;;
*-macos) echo ".dmg DMG" ;;
*) echo "roster key $1 names no bundle kind" >&2; return 1 ;;
esac
}
# validated once at top level: inside the `$(...)` below a
# failing bundle() would only empty its own substitution
for key in $TARGETS; do bundle "$key" >/dev/null || exit 1; done
asset() { # $1=key $2=ce|ce-core|CodeEraser
case "$2" in
CodeEraser) echo "CodeEraser-$VER-$1$(bundle "$1" | cut -d' ' -f1)" ;;
*) case "$1" in *-windows) echo "$2-$VER-$1.exe" ;; *) echo "$2-$VER-$1" ;; esac ;;
esac
}
mkdir dist && cd dist
n=0
for key in $TARGETS; do
for what in ce ce-core CodeEraser; do
gh release download "$TAG" --pattern "$(asset "$key" "$what")"
n=$((n + 1))
done
done
gh release download "$TAG" --pattern SHA256SUMS
extra=$(gh release view "$TAG" --json assets \
--jq '[.assets[].name] | length')
[ "$extra" = "$((n + 1))" ] || { echo "release carries $extra assets, expected $((n + 1))"; exit 1; }
sha256sum -c SHA256SUMS
check_pin() { # $1=envkey tail (PLATFORM_CE | PLATFORM_CECORE | PLATFORM_BUNDLE) $2=filename
eval "pin=\${CE_SHA256_$1:-}"
[ -n "$pin" ] || { echo "pin $1 is EMPTY — tag phase requires complete pins"; exit 1; }
got=$(sha256sum "$2" | cut -d' ' -f1)
[ "$got" = "$pin" ] || { echo "pin $1 mismatch: expected $pin got $got"; exit 1; }
echo "pin $1 ok"
}
# The installers were once the assets with no committed pin,
# checked only against a SHA256SUMS generated by the same
# mutable draft — internal consistency, not provenance. They
# are also the highest-privilege artifact in the release
# (elevated, machine-PATH-writing), so they get the same
# out-of-band commitment as the binaries.
for key in $TARGETS; do
mkey=$(echo "$key" | tr 'a-z-' 'A-Z_')
check_pin "${mkey}_CE" "$(asset "$key" ce)"
check_pin "${mkey}_CECORE" "$(asset "$key" ce-core)"
check_pin "${mkey}_$(bundle "$key" | cut -d' ' -f2)" "$(asset "$key" CodeEraser)"
done
gh release edit "$TAG" --draft=false
# The marketplace channel (O82): `ce setup`, the installers and
# both READMEs register skymanbp/CodeEraser@release, so an install
# follows releases, not main. Only a PUBLISHED release moves the
# branch — this step sits after the edit above — and only as a
# fast-forward: the tag commit descends from the previous
# release's on main, so a non-fast-forward means a hand moved the
# branch; refuse and name the remedy rather than force over it.
- name: Fast-forward the release branch to this tag
shell: bash
run: |
git push origin "HEAD:refs/heads/release" || {
echo "published, but the release branch did not fast-forward — the marketplace still points at the previous release; by hand: git push origin ${GITHUB_REF_NAME}^{commit}:refs/heads/release"; exit 1; }
# crates.io (O75): the crate goes out only after the GitHub release
# did, from the tag's own tree, and only when the repository owner has
# put a token in the CARGO_REGISTRY_TOKEN secret. Without it the dry
# run still proves the package builds and the job passes with a
# notice — the hand publish in docs/RELEASE.md §3 stays the path.
publish-crate:
if: github.event_name == 'push'
needs: verify-publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
submodules: true # tests/unit/** ships in the crate (docs/RELEASE.md §3)
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable branch, pinned 2026-08-19; version comes from rust-toolchain.toml (repo root)
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
workspaces: cli
- name: Package (dry run), then publish when the token is set
shell: bash
working-directory: cli
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
VER="${GITHUB_REF_NAME#v}"
CRATE=$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)
[ "$CRATE" = "$VER" ] || { echo "tag $VER != cli/Cargo.toml $CRATE"; exit 1; }
cargo publish --dry-run --locked
if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
echo "::notice::CARGO_REGISTRY_TOKEN is not set — the crate packaged but was not published; cd cli && cargo publish by hand (docs/RELEASE.md §3)"
exit 0
fi
# a hand publish that raced this leg is not a failure of the release
if curl -fsS -A "codeeraser-release-workflow (https://github.com/skymanbp/CodeEraser)" \
"https://crates.io/api/v1/crates/codeeraser/$VER" >/dev/null 2>&1; then
echo "::notice::crates.io already serves codeeraser $VER — nothing to publish"
exit 0
fi
cargo publish --locked
# Homebrew tap (O71): packaging/homebrew/Formula/codeeraser.rb is a
# projection of the manifest pins (scripts/packaging.js, held by
# tests/it/packaging.rs); scripts/homebrew_tap.sh puts it into the
# tap repository skymanbp/homebrew-codeeraser when HOMEBREW_TAP_TOKEN
# (a token that can write that repository) is set, so `brew install
# skymanbp/codeeraser/codeeraser` follows every release. Without the
# secret: a notice and the by-hand path (docs/RELEASE.md §3).
homebrew-tap:
if: github.event_name == 'push'
needs: verify-publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Put the formula into the tap
shell: bash
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: sh scripts/homebrew_tap.sh "${GITHUB_REF_NAME#v}"
# winget (O71): the three manifests under packaging/winget are
# generated the same way; scripts/winget_pr.sh pushes them to the
# owner's fork of microsoft/winget-pkgs and opens the pull request
# when WINGET_TOKEN (a token of the forking account) is set. Without
# the secret: a notice and the by-hand path (docs/RELEASE.md §3).
winget-pr:
if: github.event_name == 'push'
needs: verify-publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Open the winget-pkgs pull request
shell: bash
env:
WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }}
run: sh scripts/winget_pr.sh "${GITHUB_REF_NAME#v}"