Repository navigation
501 lines (446 loc) · 24.6 KB
/
Copy pathci.yml
File metadata and controls
501 lines (446 loc) · 24.6 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
name: CI
on:
push:
branches: [master]
paths-ignore:
- "**.md"
- LICENSE
# Pull requests and the merge queue always start the workflow, so the required "CI result" check
# reports on every change; a documentation-only change skips the heavy jobs inside it (the
# changes job) instead of skipping the run, which would leave that check missing.
pull_request:
branches:
- master
- "feature/*"
merge_group:
permissions:
contents: read
# A superseded run of the same ref is wasted matrix time; a master run is never cancelled,
# because its badges and its nightly publish are the point.
concurrency:
group: "${{ github.workflow }}-${{ github.ref }}"
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_NOLOGO: true
jobs:
# Decides whether the change reaches anything but Markdown and LICENSE, the files a push run
# ignores. A pull request's merge ref has the base as its first parent; a merge-queue entry names
# its base, and the entries ahead of it are part of what it tests.
changes:
name: "Changes"
runs-on: ubuntu-24.04
permissions:
contents: read
outputs:
code: ${{ steps.diff.outputs.code }}
steps:
- name: Checkout
if: github.event_name != 'push'
uses: actions/checkout@v7
with:
fetch-depth: 2
- name: Classify the diff
id: diff
env:
EVENT: ${{ github.event_name }}
QUEUE_BASE: ${{ github.event.merge_group.base_sha }}
run: |
if [ "$EVENT" = push ]; then
echo "code=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$EVENT" = merge_group ]; then
git fetch --no-tags --depth=1 origin "$QUEUE_BASE"
base="$QUEUE_BASE"
else
base="$(git rev-parse HEAD^1)"
fi
# Raw NUL-separated paths, not Git's quoted display form, and no rename detection: a
# source file renamed to Markdown is a deleted source file, and its old path must count.
git diff --name-only --no-renames -z "$base" HEAD > "$RUNNER_TEMP/changed-paths"
code=false
while IFS= read -r -d '' path; do
printf '%s\n' "$path"
case "$path" in
*.md | LICENSE) ;;
*) code=true ;;
esac
done < "$RUNNER_TEMP/changed-paths"
echo "code=$code" >> "$GITHUB_OUTPUT"
# The OS-independent gates run once, beside the matrix instead of inside its Linux leg, where
# they delayed that leg's tests by about 5 minutes (2026-09-26, run 36231256172). None of them
# needs the pinned server: the formatter compiles in memory from the restored projects, and
# generation reads only committed inputs. The submodule is checked out only for the pinned Bun
# version the clone detector installs with.
#
# The analyzer wall also runs here, once, for every target compilation. Every OS compiles the
# same LF-normalized sources (.gitattributes), and the one OS-conditioned setting that reaches a
# compilation is the Windows-only net472 test target, which IncludeNet472Tests adds here.
# MSBuild reads the variable as a property in every step, so restore, format and build see the
# same targets.
lint:
name: "Lint & Generated Output"
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read
env:
IncludeNet472Tests: true
steps:
# The submodule carries the pinned Bun version the clone detector installs with.
- name: Checkout
uses: actions/checkout@v7
with:
submodules: true
- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
# The pinned .NET 11 release-candidate SDK carries only the .NET 11 runtime; the 10.0
# SDK beside it runs the tools, which stay on the LTS release.
global-json-file: global.json
dotnet-version: 10.0.x
# Reads the Linux leg's cache; that leg restores a superset of these packages and is the
# one writer of the key.
- name: Restore NuGet packages cache
uses: actions/cache/restore@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'nuget.config', 'dotnet-tools.json', 'Directory.Packages.props', 'Directory.Build.props', 'Directory.Build.targets', 'src/*/*.csproj', 'tests/Directory.Build.props', 'tests/*/*.csproj', 'tools/*/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
- name: Restore local tools
run: dotnet tool restore
- name: Setup pinned Bun
uses: ./.github/actions/setup-pinned-bun
# The clone gate's detector, pinned with its own lockfile so every platform's binary is
# the reviewed one.
- name: Install clone detector
working-directory: tools/jscpd
run: bun install --frozen-lockfile --ignore-scripts
- name: Detect slop
run: dotnet tool run slopwatch analyze --exclude ".scratchpad/**,external/**" --fail-on warning
- name: Restore
run: dotnet restore
# Physical formatting gate (one leg is enough — results are OS-independent). This retains
# encoding/EOL/whitespace without rerunning the semantic analyzer set.
- name: Verify formatting
run: dotnet format whitespace --verify-no-changes --no-restore
# Build owns compiler/CA/third-party diagnostics. This pass retains all warning-level Roslyn
# style rules plus import organization, including rules Roslyn cannot enforce during build.
- name: Verify code style
run: dotnet format style --verify-no-changes --no-restore --severity warn
# Build owns the analyzer wall. The matrix legs compile without analyzers, so this build is the
# CI wall for every target, net472 tests included.
- name: Build with analyzers
run: dotnet build --configuration Release --no-restore
env:
DOTNET_TieredPGO: 0
# The Release build above produces every shipped asset; validate both packages before merge.
- name: Validate packages
run: |
dotnet pack src/OpenCode.Sdk/OpenCode.Sdk.csproj --no-build --no-restore --configuration Release -p:EnablePackageValidation=true --output "${{ runner.temp }}/package-validation"
dotnet pack src/OpenCode.Sdk.Extensions/OpenCode.Sdk.Extensions.csproj --no-build --no-restore --configuration Release -p:EnablePackageValidation=true --output "${{ runner.temp }}/package-validation"
# Smoke-runs the repo tooling entry; exercises the committed executable bit and the
# shebang, which only take effect on a Linux checkout.
- name: Tooling entry smoke
run: ./tools/opencode-tool.cs --help
# Regenerating the pinned profile must be a byte-identical no-op on a clean checkout.
- name: Verify generated output
run: dotnet run --file tools/opencode-tool.cs -- generate --verify
# Hand-written src/ and tools/ may not gain a clone their committed baselines do not hold;
# tests and generated output are reported.
- name: Verify clones
run: dotnet run --file tools/opencode-tool.cs -- verify-clones --report-directory "${{ runner.temp }}/clone-reports"
# A failed clone gate prints each new clone's place; the full reports go along for the rest.
- name: Upload clone reports
if: failure()
uses: actions/upload-artifact@v7
with:
name: clone-reports-${{ github.event.pull_request.number || github.run_number }}
path: ${{ runner.temp }}/clone-reports
if-no-files-found: ignore
retention-days: 7
build-and-test:
name: "Build & Test (${{ matrix.name }})"
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ${{ matrix.os }}
permissions:
contents: read
checks: write
strategy:
fail-fast: false
matrix:
# test-modules: the main Test pass's --max-parallel-test-modules, measured per runner.
include:
- os: windows-2025
name: Windows
test-modules: 4
- os: ubuntu-24.04
name: Linux
test-modules: 4
- os: macos-15
name: macOS
test-modules: 3
steps:
- name: Checkout
uses: actions/checkout@v7
with:
submodules: true
- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
# The SDK comes from the repository pin, the .NET 11 release candidate, which carries only
# the .NET 11 runtime; the 10.0 SDK is installed beside it for the net10.0 test legs and
# for the tools, which run on the LTS release.
global-json-file: global.json
dotnet-version: 10.0.x
# Bun, the exact ripgrep the server resolves, and the pinned server's dependencies. Shared
# with the distributed-build consumer leg so the two lanes cannot drift.
- name: Setup pinned server runtime
uses: ./.github/actions/setup-pinned-server
# The Tools clone tests run the pinned detector itself, so its ignore pattern is proven on
# every platform's binary.
- name: Install clone detector
working-directory: tools/jscpd
run: bun install --frozen-lockfile --ignore-scripts
# The key hashes only the repository's own restore inputs. A '**' pattern also walked
# external/opencode/node_modules, which the step above installs, once to restore and again
# to save: about a minute per Windows and macOS job.
- name: Restore NuGet packages cache
id: nuget-cache
uses: actions/cache/restore@v6
with:
path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('global.json', 'nuget.config', 'dotnet-tools.json', 'Directory.Packages.props', 'Directory.Build.props', 'Directory.Build.targets', 'src/*/*.csproj', 'tests/Directory.Build.props', 'tests/*/*.csproj', 'tools/*/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-
# Dynamic PGO is off for the build only. The compiler server and the MSBuild nodes live
# for one cold build here, and turning it off shortened the Build step on every leg
# (2026-09-26, run 36269677228, commit db330cc: four cold builds per VM in rotated order,
# mean paired against the same VM's default build: Linux -48 s (-16%), Windows -77 s
# (-17%), macOS -62 s (-16%)). A JIT setting does not change the compiler's output; a
# dev-01 build with it produced the identical assembly hash. The tests run on the default
# runtime settings.
#
# The lint job runs the analyzer wall once for every target compilation, so these legs only
# compile, test and publish. Analyzers were 72-79% of this step: skipping them shortened it
# by 228 s on Linux, 363 s on Windows and 342 s on macOS (2026-09-26, run 36269677228).
# Source generators still run: RunAnalyzers=false only passes SkipAnalyzers to the compiler.
- name: Build
id: build
run: dotnet build --configuration Release -p:RunAnalyzers=false
env:
DOTNET_TieredPGO: 0
# The build restored every package the later steps use. Only master runs write the cache;
# the setup-pinned-server action explains why.
- name: Save NuGet packages cache
if: github.ref == 'refs/heads/master' && github.event_name == 'push' && steps.nuget-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v6
with:
path: ~/.nuget/packages
key: ${{ steps.nuget-cache.outputs.cache-primary-key }}
# TUnit's always-on HTML report self-uploads as a workflow artifact, but only if these
# runtime vars (normally JS/Docker-action-only) are re-exported to run steps.
- name: Enable TUnit report artifact upload
uses: actions/github-script@v9
with:
script: |
core.exportVariable('ACTIONS_RUNTIME_TOKEN', process.env['ACTIONS_RUNTIME_TOKEN']);
core.exportVariable('ACTIONS_RESULTS_URL', process.env['ACTIONS_RESULTS_URL']);
# The number of test modules at once is the matrix's test-modules value, measured per runner
# rather than left to MTP's processor-count default: Windows 4, Linux 4, macOS 3 (four saves
# only about 3% there).
#
# The background-service elections and the native AOT packed-consumer publish run in passes
# of their own after every other test. A ten-caller election starts some twenty to thirty-four
# source-run servers, and a timed test in another host that ran in those seconds missed its
# bound on Windows and macOS alike; the native AOT publish runs one ILC compile that loads
# every core of the runner for about a minute, which the timing-sensitive launcher tests
# cannot run beside either. TUnit's keys order tests inside one host only
# (tests/Shared/TestCategories.cs). Each later pass runs its hosts one at a time for the same
# reason. They run after a failed first pass (not after a failed build), so the report sees
# every pass, and they leave the HTML report upload to the first pass, whose artifact names
# (assembly, OS, framework) they would repeat.
#
# Every pass ends a hung module inside its step under a session --timeout; the listing below,
# written only when the main pass fails, narrows the rows in flight (quality-gates.md).
- name: Test
id: test
run: dotnet test --configuration Release --no-build --max-parallel-test-modules ${{ matrix.test-modules }} -- --report-trx --results-directory "${{ github.workspace }}/test-results/main" --treenode-filter "/*/*/*/*[(Category!=ServiceElection)&(Category!=NativeAot)]" --minimum-expected-tests 1 --timeout 25m
env:
TUNIT_ARTIFACT_RETENTION_DAYS: 7
- name: List tests (main, after a failed pass)
if: ${{ !cancelled() && steps.test.outcome == 'failure' }}
run: dotnet test --configuration Release --no-build --list-tests -- --treenode-filter "/*/*/*/*[(Category!=ServiceElection)&(Category!=NativeAot)]" > "${{ github.workspace }}/test-results/main/listed-tests.txt"
- name: Test (service elections)
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: dotnet test --project "${{ github.workspace }}/tests/OpenCode.Sdk.Tests/OpenCode.Sdk.Tests.csproj" --configuration Release --no-build --max-parallel-test-modules 1 -- --report-trx --results-directory "${{ github.workspace }}/test-results/service-elections" --treenode-filter "/*/*/*/*[Category=ServiceElection]" --minimum-expected-tests 1 --timeout 25m
env:
TUNIT_ARTIFACT_RETENTION_DAYS: 7
TUNIT_DISABLE_ARTIFACT_UPLOAD: true
# One packed-consumer publish, shared by the pass's three tests through the session fixture
# (tests/OpenCode.Sdk.Tools.Tests/Packaging/PackedConsumerFeed.cs); the ILC compile needs the
# host's native toolchain and runs the host's own runtime identifier.
- name: Test (native AOT)
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: dotnet test --project "${{ github.workspace }}/tests/OpenCode.Sdk.Tools.Tests/OpenCode.Sdk.Tools.Tests.csproj" --configuration Release --no-build --max-parallel-test-modules 1 -- --report-trx --results-directory "${{ github.workspace }}/test-results/native-aot" --treenode-filter "/*/*/*/*[Category=NativeAot]" --minimum-expected-tests 1 --timeout 25m
env:
TUNIT_ARTIFACT_RETENTION_DAYS: 7
TUNIT_DISABLE_ARTIFACT_UPLOAD: true
# Fail-closed roster assertion: every module and target framework the committed
# tests/test-roster.json lists for this OS must have run in the pass and executed at least one
# test, so a dropped test project, a dropped target framework, or broken test discovery
# cannot stay green with less coverage. Adding or removing a test module updates the roster in
# the same change.
- name: Verify test roster (main)
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: dotnet run --file tools/opencode-tool.cs -- verify-test-roster --roster tests/test-roster.json --pass main --os ${{ runner.os }} --results "${{ github.workspace }}/test-results/main"
- name: Verify test roster (service elections)
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: dotnet run --file tools/opencode-tool.cs -- verify-test-roster --roster tests/test-roster.json --pass service-elections --os ${{ runner.os }} --results "${{ github.workspace }}/test-results/service-elections"
- name: Verify test roster (native AOT)
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: dotnet run --file tools/opencode-tool.cs -- verify-test-roster --roster tests/test-roster.json --pass native-aot --os ${{ runner.os }} --results "${{ github.workspace }}/test-results/native-aot"
- name: Publish test results
id: test-results
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
if: success() || failure()
# Allow fork PRs to fail gracefully (permission error) without breaking the workflow.
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository }}
with:
name: "Test Results (${{ matrix.name }})"
path: "test-results/**/*.trx"
reporter: dotnet-trx
path-replace-backslashes: true
fail-on-error: true
# Badge posting failure must not fail CI: the badgesmith.cs tool exits 0 on a failed post
# unless --fail-on-error is passed (which this action never does), and continue-on-error
# here is defense in depth against pre-flight validation failures. always() plus the
# ref/event guard mirrors badge-smith's own ci-cd.yml so a failing master run still posts
# the (failing) counts instead of leaving the badge stale.
# GitHub Actions treats any remote reference under .github/workflows/ as a REUSABLE
# WORKFLOW and refuses it inside steps ("reusable workflows should be referenced at the
# top-level jobs.*.uses key"), so the composite action cannot be consumed remotely from its
# current home. Until badge-smith relocates it (e.g. .github/actions/) and re-tags, we check
# the pinned tag out and consume the action through the local-path form badge-smith itself
# uses; the action is github.action_path-relative, so the relocated checkout is
# self-contained.
- name: Checkout BadgeSmith action
if: always() && github.ref == 'refs/heads/master' && github.event_name == 'push'
continue-on-error: true
uses: actions/checkout@v7
with:
repository: localstack-dotnet/badge-smith
# Pinned to the commit the live api.localstackfor.net deployment runs, keeping the
# signing tool and the server in lockstep. (The 2026-08-31 401s were ultimately a
# corrupted repository secret, not the tag - but deploy-aligned pinning stays the
# safer default until badge-smith tags releases in lockstep with its deployments.)
ref: d778400bd16cee44994f1c9bb68121e6a54d66cf
path: .badge-smith
- name: Update test badge via BadgeSmith API
if: always() && github.ref == 'refs/heads/master' && github.event_name == 'push'
continue-on-error: true
uses: ./.badge-smith/.github/workflows/update-test-badge
with:
platform: ${{ matrix.name }}
test_passed: '${{ steps.test-results.outputs.passed || 0 }}'
test_failed: '${{ steps.test-results.outputs.failed || 0 }}'
test_skipped: '${{ steps.test-results.outputs.skipped || 0 }}'
test_url_html: ${{ steps.test-results.outputs.url_html || '' }}
commit_sha: '${{ github.sha }}'
run_id: '${{ github.run_id }}'
repository: '${{ github.repository }}'
server_url: '${{ github.server_url }}'
api_base_url: 'https://api.localstackfor.net'
hmac_secret: '${{ secrets.TESTDATASECRET }}'
- name: Upload test artifacts
uses: actions/upload-artifact@v7
if: always()
continue-on-error: true
with:
name: test-results-${{ matrix.name }}-${{ github.event.pull_request.number || github.run_number }}
path: test-results/**
retention-days: 7
# The one required check. A skipped matrix job reports under its unexpanded name, so the matrix
# legs cannot be required one by one; this job stands for all of them. It fails closed: a change
# that reaches code needs every job green, and a documentation-only change needs the changes job.
ci-result:
name: "CI result"
needs: [changes, lint, build-and-test]
if: always()
runs-on: ubuntu-24.04
permissions: {}
steps:
- name: Require every needed job
env:
CHANGES: ${{ needs.changes.result }}
CODE: ${{ needs.changes.outputs.code }}
LINT: ${{ needs.lint.result }}
BUILD_AND_TEST: ${{ needs.build-and-test.result }}
run: |
echo "changes=$CHANGES code=$CODE lint=$LINT build-and-test=$BUILD_AND_TEST"
[ "$CHANGES" = success ] || exit 1
if [ "$CODE" = true ]; then
[ "$LINT" = success ] && [ "$BUILD_AND_TEST" = success ] || exit 1
else
[ "$CODE" = false ] || exit 1
fi
# Runs once green (needs the lint job and the matrixed build-and-test job, which GitHub Actions
# only satisfies once every OS leg completes) and only on a real push to master, never on a PR.
# Publishes a -nightly package to GitHub Packages for every master push that runs this workflow,
# so the code of the last such push is installable; the stable, human-triggered lane lives in
# publish-nuget.yml.
publish-nightly:
name: "Publish nightly (GitHub Packages)"
needs: [lint, build-and-test]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
# No submodules: GenerationRequest.RepositoryDefault (tools/OpenCode.Sdk.Tools/Generator/
# GenerationRequest.cs) points generate --verify at spec/openapi.json, tools/curation.json,
# and tools/generation-profile.txt — all committed. Packing builds only src/OpenCode.Sdk and
# src/OpenCode.Sdk.Extensions, which never reference external/opencode; that submodule is a
# test-only fixture the release lane does not exercise.
- name: Checkout
uses: actions/checkout@v7
- name: Setup .NET SDK
uses: actions/setup-dotnet@v6
with:
# The pinned .NET 11 release-candidate SDK carries only the .NET 11 runtime; the 10.0
# SDK beside it runs the tools, which stay on the LTS release.
global-json-file: global.json
dotnet-version: 10.0.x
# Two-layer defense against a partial generated surface (Directory.Build.targets' header
# comment): that target refuses to pack on a missing or stale .generation-incomplete marker,
# and this step additionally reruns the generator in --verify mode first, so a hand-edited or
# stale marker is caught by regeneration rather than only by the marker's own text.
- name: Verify generated output
run: dotnet run --file tools/opencode-tool.cs -- generate --verify
# The g prefix keeps the commit identifier alphanumeric: an all-digit short SHA with a
# leading zero (0044030) is not a valid SemVer identifier, and restore fails on it.
- name: Compute nightly version suffix
id: version
run: echo "suffix=nightly.$(date -u +%Y%m%d).g${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- name: Pack
run: dotnet pack --configuration Release --version-suffix "${{ steps.version.outputs.suffix }}" --output packages
# dotnet nuget push implements its own glob expansion; the path is quoted so the shell does
# not expand it first (two packable projects produce two .nupkg files here).
#
# The feed is the repository owner's, so it follows the repository: GitHub Packages links a
# pushed package to a repository only when the nuspec's repository URL has the same owner.
- name: Push to GitHub Packages
env:
PACKAGES_KEY: ${{ secrets.GITHUB_TOKEN }}
PACKAGES_FEED: https://nuget.pkg.github.com/${{ github.repository_owner }}/index.json
run: >
dotnet nuget push "packages/*.nupkg"
--source "$PACKAGES_FEED"
--api-key "$PACKAGES_KEY"
--skip-duplicate