kinjo 0.3.8

Kinjo: mDNS TUI and commands launch for local network services
Documentation
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
name: Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: "Merged stable version without v (for example 0.3.0)"
        required: true
        type: string
      sha:
        description: "Optional exact commit to release (defaults to the merged release/v<version> PR)"
        required: false
        default: ""
        type: string
      publish:
        description: "Publish after validation and protected-environment approval"
        required: true
        default: false
        type: boolean

permissions:
  contents: read

concurrency:
  group: release-publication
  cancel-in-progress: false

env:
  CARGO_TERM_COLOR: always

jobs:
  preflight:
    name: Validate release identity
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    permissions:
      contents: read
      pull-requests: read
    outputs:
      tag: ${{ steps.identity.outputs.tag }}
      sha: ${{ steps.identity.outputs.sha }}
      version: ${{ steps.identity.outputs.version }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          fetch-depth: 0
          persist-credentials: false

      # Pin the release to an immutable commit, not to main's moving tip. By
      # default that commit is the merge commit of the merged release/v<version>
      # PR that Prepare Release opened, so unrelated merges (Dependabot, etc.)
      # after the version was cut cannot change or fail the release, and every
      # retry resolves the same commit. An explicit sha input overrides the
      # lookup for hand-merged or otherwise unusual cases.
      - name: Resolve and validate the release commit
        id: identity
        env:
          VERSION: ${{ inputs.version }}
          SHA_OVERRIDE: ${{ inputs.sha }}
          GH_TOKEN: ${{ github.token }}
        run: |
          scripts/release/check-version.sh main
          scripts/release/check-version.sh stable "$VERSION"

          # The dispatch tip of main; the release commit must be one of its
          # ancestors, i.e. an already-merged, reviewed commit.
          main_tip="$(git rev-parse HEAD)"
          branch="release/v${VERSION}"

          if [ -n "$SHA_OVERRIDE" ]; then
            scripts/release/check-version.sh sha "$SHA_OVERRIDE"
            sha="$SHA_OVERRIDE"
          else
            sha="$(gh pr list --repo "$GITHUB_REPOSITORY" --state merged \
              --head "$branch" --base main --json mergeCommit,mergedAt \
              --jq 'sort_by(.mergedAt) | last | .mergeCommit.oid // empty')"
            if [ -z "$sha" ]; then
              echo "::error::no merged $branch PR found; run Prepare Release and merge it, or pass an explicit sha"
              exit 1
            fi
          fi

          if ! git cat-file -e "${sha}^{commit}" 2>/dev/null; then
            echo "::error::$sha is not a known commit in this repository"
            exit 1
          fi
          if ! git merge-base --is-ancestor "$sha" "$main_tip"; then
            echo "::error::$sha is not an ancestor of main; only merged commits can be released"
            exit 1
          fi

          git checkout --quiet --detach "$sha"
          scripts/release/check-version.sh manifest "$VERSION"

          echo "Releasing v$VERSION from $sha"
          {
            echo "version=$VERSION"
            echo "tag=v$VERSION"
            echo "sha=$sha"
          } >> "$GITHUB_OUTPUT"

  rust-ci:
    needs: preflight
    uses: ./.github/workflows/ci-test.yml
    with:
      caller-run-id: ${{ github.run_id }}
      ref: ${{ needs.preflight.outputs.sha }}

  audit:
    needs: preflight
    uses: ./.github/workflows/audit.yml
    with:
      caller-run-id: ${{ github.run_id }}
      ref: ${{ needs.preflight.outputs.sha }}

  nix:
    needs: preflight
    uses: ./.github/workflows/nix.yml
    with:
      caller-run-id: ${{ github.run_id }}
      ref: ${{ needs.preflight.outputs.sha }}

  workflow-lint:
    needs: preflight
    uses: ./.github/workflows/actionlint.yml
    with:
      caller-run-id: ${{ github.run_id }}
      ref: ${{ needs.preflight.outputs.sha }}

  source-package:
    name: Stage crate and SBOM
    needs: [preflight, rust-ci, audit, nix, workflow-lint]
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          ref: ${{ needs.preflight.outputs.sha }}
          persist-credentials: false

      - name: Set up build environment
        uses: ./.github/actions/setup

      - name: Install pinned SBOM generator
        uses: taiki-e/cache-cargo-install-action@417450f3c33ee20393705369577571770643d4c7 # v3
        with:
          tool: cargo-cyclonedx@0.5.9

      - name: Package and verify the crate
        env:
          VERSION: ${{ needs.preflight.outputs.version }}
        run: |
          scripts/release/check-version.sh manifest "$VERSION"
          cargo package --locked --verbose
          cargo publish --dry-run --locked --verbose
          cargo cyclonedx --format json --target all --all-features
          # cargo-cyclonedx stamps a wall-clock timestamp and a random
          # serialNumber, so two runs of an identical tree differ byte-for-byte.
          # That would break a resumed publish, whose byte-identity check compares
          # the regenerated SBOM against the one already uploaded. Pin the
          # timestamp to the commit time and replace the random serial with a
          # UUID derived deterministically from the release commit. The serial
          # cannot simply be dropped: actions/attest requires a serialNumber and
          # rejects a CycloneDX document without one as an unsupported format.
          commit_iso="$(TZ=UTC git show -s --format=%cd --date=format-local:%Y-%m-%dT%H:%M:%SZ HEAD)"
          serial="urn:uuid:$(python3 -c "import uuid, sys; print(uuid.uuid5(uuid.NAMESPACE_URL, 'kinjo-sbom-' + sys.argv[1]))" "$(git rev-parse HEAD)")"
          jq --arg ts "$commit_iso" --arg serial "$serial" \
            '.serialNumber = $serial | .metadata.timestamp = $ts' \
            kinjo.cdx.json > "kinjo-${VERSION}.cdx.json"
          rm -f kinjo.cdx.json
          git archive --format=tar --prefix="kinjo-${VERSION}/" HEAD |
            gzip -n > "kinjo-${VERSION}.tar.gz"
          tar -tzf "kinjo-${VERSION}.tar.gz" | grep -Fx "kinjo-${VERSION}/Cargo.toml"
          # upload-artifact roots an artifact at the least common ancestor of its
          # paths, so the crate has to sit beside the other two or it would be
          # stored under target/package/ and land in the wrong place on download.
          mv "target/package/kinjo-${VERSION}.crate" .

      - name: Upload staged source artifacts
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: release-source-${{ needs.preflight.outputs.version }}
          path: |
            kinjo-${{ needs.preflight.outputs.version }}.crate
            kinjo-${{ needs.preflight.outputs.version }}.cdx.json
            kinjo-${{ needs.preflight.outputs.version }}.tar.gz
          if-no-files-found: error
          retention-days: 7

  debian:
    needs: [preflight, rust-ci, audit, nix, workflow-lint]
    uses: ./.github/workflows/release-deb.yml
    with:
      version: ${{ needs.preflight.outputs.version }}
      ref: ${{ needs.preflight.outputs.sha }}

  macos:
    needs: [preflight, rust-ci, audit, nix, workflow-lint]
    uses: ./.github/workflows/release-macos.yml
    with:
      version: ${{ needs.preflight.outputs.version }}
      ref: ${{ needs.preflight.outputs.sha }}

  stage:
    name: Assemble and verify the candidate artifacts
    needs: [preflight, source-package, debian, macos]
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          ref: ${{ needs.preflight.outputs.sha }}
          persist-credentials: false

      - name: Download every staged artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
        with:
          pattern: release-*-${{ needs.preflight.outputs.version }}
          path: dist
          merge-multiple: true

      # Runs in both modes, so a dry run proves the naming contract the publisher
      # depends on instead of failing after the release approval.
      - name: Verify the staged artifact set
        env:
          VERSION: ${{ needs.preflight.outputs.version }}
        run: scripts/release/check-artifacts.sh dist "$VERSION"

  dry-run-complete:
    name: Release candidate is ready
    if: ${{ !inputs.publish }}
    needs: [preflight, stage]
    runs-on: ubuntu-24.04
    timeout-minutes: 5
    steps:
      - run: echo "v${{ needs.preflight.outputs.version }} passed the complete release candidate gate; nothing was published."

  publish:
    name: Publish approved release
    if: ${{ inputs.publish }}
    needs: [preflight, stage]
    runs-on: ubuntu-24.04
    timeout-minutes: 30
    environment:
      name: release
    permissions:
      contents: write
      id-token: write
      attestations: write
      artifact-metadata: write
    # The source checkout lives under source/, so the workspace root is not a git
    # repository. Give gh an explicit repo so its release subcommands never try to
    # infer one from a local remote and fail with "not a git repository".
    env:
      GH_REPO: ${{ github.repository }}

    steps:
      - name: Download the validated source
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
        with:
          ref: ${{ needs.preflight.outputs.sha }}
          path: source
          persist-credentials: false

      - name: Download every staged artifact
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
        with:
          pattern: release-*-${{ needs.preflight.outputs.version }}
          path: dist
          merge-multiple: true

      - name: Create checksums and validate staged names
        working-directory: source
        env:
          VERSION: ${{ needs.preflight.outputs.version }}
        run: scripts/release/check-artifacts.sh ../dist "$VERSION"

      # The release is pinned to the merged version commit, an ancestor of main's
      # tip rather than this run's HEAD. Under immutable releases, creating a
      # release whose tag would land on a non-HEAD commit is rejected for every
      # installation token -- both GITHUB_TOKEN and a GitHub App token -- with
      # "Resource not accessible by integration"; only a user PAT can. To avoid a
      # long-lived PAT we push the tag ourselves with the App token (a plain git
      # push of this checkout's HEAD, which is the release commit) and then create
      # the release from the already-existing tag, so no token has to create a
      # non-HEAD tag through the releases API. The App token drives every
      # release-write gh call; build-provenance attestations stay on
      # GITHUB_TOKEN + OIDC.
      - name: Mint release App token
        id: app-token
        uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
        with:
          app-id: ${{ vars.RELEASE_APP_ID }}
          private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }}
          owner: ${{ github.repository_owner }}
          repositories: ${{ github.event.repository.name }}

      - name: Create or verify the draft release
        id: draft
        working-directory: source
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TAG: ${{ needs.preflight.outputs.tag }}
          SHA: ${{ needs.preflight.outputs.sha }}
        run: |
          metadata="$RUNNER_TEMP/release.json"
          # Make sure the immutable tag exists at the exact release commit before
          # creating the release. On a resume it already exists and must point at
          # SHA; otherwise push it from this checkout, whose HEAD is the release
          # commit, using the App token via git (the releases API cannot create a
          # non-HEAD tag under immutable releases).
          if tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null)"; then
            if [ "$tag_sha" != "$SHA" ]; then
              echo "::error::$TAG already exists at $tag_sha instead of $SHA"
              exit 1
            fi
          else
            head_sha="$(git rev-parse HEAD)"
            if [ "$head_sha" != "$SHA" ]; then
              echo "::error::source checkout is at $head_sha, not the release commit $SHA"
              exit 1
            fi
            gh auth setup-git
            git push origin "HEAD:refs/tags/${TAG}"
          fi

          # Create the release from the now-existing tag (no --target, so no tag
          # is created through the API). A resumed run reuses the existing draft.
          if ! gh release view "$TAG" --json isDraft,isPrerelease,name > "$metadata" 2>/dev/null; then
            gh release create "$TAG" --draft --generate-notes --title "$TAG"
            gh release view "$TAG" --json isDraft,isPrerelease,name > "$metadata"
          fi
          # Read raw values only: jq -e would exit non-zero on a legitimate
          # false/null (isDraft is false for a resumed published release,
          # isPrerelease is false for every normal release), which under set -e
          # would abort the step before the explicit checks below can run.
          state="$(jq -r .isDraft "$metadata")"
          name="$(jq -r .name "$metadata")"
          prerelease="$(jq -r .isPrerelease "$metadata")"
          if [ "$name" != "$TAG" ] || [ "$prerelease" != false ]; then
            echo "::error::$TAG has conflicting release metadata"
            exit 1
          fi
          echo "is-draft=$state" >> "$GITHUB_OUTPUT"

      - name: Upload only new or byte-identical assets
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TAG: ${{ needs.preflight.outputs.tag }}
        run: |
          mkdir -p existing-assets
          while IFS= read -r name; do
            test -f "dist/$name" || {
              echo "::error::release contains unexpected asset $name"
              exit 1
            }
          done < <(gh release view "$TAG" --json assets --jq '.assets[].name')
          for file in dist/*; do
            name="${file##*/}"
            if gh release view "$TAG" --json assets --jq '.assets[].name' | grep -Fxq "$name"; then
              gh release download "$TAG" --pattern "$name" --dir existing-assets
              cmp "$file" "existing-assets/$name" || {
                echo "::error::published asset $name differs from the staged artifact"
                exit 1
              }
            else
              gh release upload "$TAG" "$file"
            fi
          done

      - name: Attest build provenance
        uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
        with:
          subject-checksums: dist/SHA256SUMS

      - name: Attest the CycloneDX SBOM
        uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
        with:
          subject-checksums: dist/SHA256SUMS
          sbom-path: dist/kinjo-${{ needs.preflight.outputs.version }}.cdx.json

      - name: Check whether crates.io already has this exact crate
        id: crate-state
        env:
          VERSION: ${{ needs.preflight.outputs.version }}
        run: |
          response="$RUNNER_TEMP/crates-version.json"
          status="$(curl --proto '=https' --proto-redir '=https' -sSLo "$response" -w '%{http_code}' \
            -H 'User-Agent: kinjo-release-workflow' \
            "https://crates.io/api/v1/crates/kinjo/${VERSION}")"
          case "$status" in
            200)
              published="$(jq -er '.version.checksum' "$response")"
              staged="$(sha256sum "dist/kinjo-${VERSION}.crate" | cut -d' ' -f1)"
              if [ "$published" != "$staged" ]; then
                echo "::error::crates.io kinjo $VERSION has checksum $published, expected $staged"
                exit 1
              fi
              echo "publish=false" >> "$GITHUB_OUTPUT"
              ;;
            404)
              echo "publish=true" >> "$GITHUB_OUTPUT"
              ;;
            *)
              echo "::error::crates.io returned HTTP $status"
              cat "$response"
              exit 1
              ;;
          esac

      # A crates.io version is permanent, so the bytes cargo is about to upload
      # have to be shown identical to the attested artifact before the upload,
      # never after it.
      - name: Reproduce the staged crate before publishing it
        if: steps.crate-state.outputs.publish == 'true'
        working-directory: source
        env:
          VERSION: ${{ needs.preflight.outputs.version }}
        run: |
          cargo package --locked --no-verify --verbose
          cmp "target/package/kinjo-${VERSION}.crate" "../dist/kinjo-${VERSION}.crate"

      - name: Obtain a short-lived crates.io token
        if: steps.crate-state.outputs.publish == 'true'
        id: crates-auth
        uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
      - name: Publish crates.io
        if: steps.crate-state.outputs.publish == 'true'
        working-directory: source
        env:
          CARGO_REGISTRY_TOKEN: ${{ steps.crates-auth.outputs.token }}
        run: cargo publish --locked --no-verify --verbose

      - name: Verify the immutable crates.io checksum
        env:
          VERSION: ${{ needs.preflight.outputs.version }}
        run: |
          expected="$(sha256sum "dist/kinjo-${VERSION}.crate" | cut -d' ' -f1)"
          for attempt in {1..12}; do
            response="$RUNNER_TEMP/crates-version-${attempt}.json"
            status="$(curl --proto '=https' --proto-redir '=https' -sSLo "$response" -w '%{http_code}' \
              -H 'User-Agent: kinjo-release-workflow' \
              "https://crates.io/api/v1/crates/kinjo/${VERSION}")"
            if [ "$status" = 200 ]; then
              actual="$(jq -er '.version.checksum' "$response")"
              test "$actual" = "$expected" || {
                echo "::error::crates.io checksum $actual does not match $expected"
                exit 1
              }
              exit 0
            fi
            if [ "$status" != 404 ]; then
              echo "::error::crates.io returned HTTP $status"
              cat "$response"
              exit 1
            fi
            sleep 5
          done
          echo "::error::kinjo $VERSION did not become visible on crates.io"
          exit 1

      - name: Publish the immutable GitHub release
        if: steps.draft.outputs.is-draft == 'true'
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TAG: ${{ needs.preflight.outputs.tag }}
        run: gh release edit "$TAG" --draft=false --latest

      - name: Verify the release is public and immutably tagged
        env:
          GH_TOKEN: ${{ steps.app-token.outputs.token }}
          TAG: ${{ needs.preflight.outputs.tag }}
          SHA: ${{ needs.preflight.outputs.sha }}
        run: |
          test "$(gh release view "$TAG" --json isDraft --jq .isDraft)" = false
          # Publishing created the tag at the release target. Confirm the tag ref
          # now exists and resolves to the exact validated commit. Allow a few
          # retries for the ref to become readable right after publication.
          for _ in 1 2 3 4 5; do
            if tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${TAG}" --jq .object.sha 2>/dev/null)"; then
              test "$tag_sha" = "$SHA" || {
                echo "::error::$TAG targets $tag_sha instead of $SHA"
                exit 1
              }
              exit 0
            fi
            sleep 3
          done
          echo "::error::$TAG has no tag ref after publication"
          exit 1

  homebrew:
    name: Open validated Homebrew PR
    if: ${{ inputs.publish }}
    needs: [preflight, publish]
    uses: ./.github/workflows/update-homebrew-tap.yml
    # secrets: inherit is required for the reusable workflow's job-level
    # environment secret (RELEASE_APP_PRIVATE_KEY) to resolve. Without it a
    # called workflow's environment secrets silently resolve to "" even though
    # the environment's variables (RELEASE_APP_ID) do resolve.
    secrets: inherit
    with:
      tag: ${{ needs.preflight.outputs.tag }}
      sha: ${{ needs.preflight.outputs.sha }}