From 830abc959c4bfde6443c47c17f937aefa2ca964f Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 18:45:10 +0100 Subject: [PATCH 01/23] ci: stop leaving the checkout token on disk actions/checkout persists GITHUB_TOKEN into .git/config by default, so every later step in the job can read it, including cargo build scripts, npm install scripts and npx tools in jobs that hold contents: write. Nothing in these workflows pushes through the checkout: the tap and bucket scripts clone with their own token, and the two git fetches in release.yml read a public repository. So every checkout now sets persist-credentials: false. Audit M19 (ci-release M-3). --- .github/workflows/audit.yml | 4 ++++ .github/workflows/ci.yml | 15 +++++++++++++++ .github/workflows/gui-render.yml | 2 ++ .github/workflows/installer-upgrade.yml | 2 ++ .github/workflows/msstore.yml | 2 ++ .github/workflows/pages.yml | 2 ++ .github/workflows/publish-release.yml | 1 + .github/workflows/publish.yml | 10 ++++++++++ .github/workflows/release-drafter.yml | 1 + .github/workflows/release.yml | 4 ++++ .github/workflows/site-preview.yml | 2 ++ .github/workflows/site.yml | 4 ++++ 12 files changed, 49 insertions(+) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index ee780d93..b496bbe7 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -48,6 +48,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust # See ci.yml's lint job for why this pin keeps a `# stable` comment @@ -106,6 +108,8 @@ jobs: path: apps/netscli-gui steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7184a33..2d8f85fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - id: filter shell: bash @@ -108,6 +109,8 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: # 22, matching pages.yml / release.yml / site.yml / site-preview.yml. @@ -139,6 +142,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # install.sh is user-facing; the release/ scripts hold GH_TOKEN and # push to three package registries. Both are worth linting, and # neither is exercised by any other job. @@ -182,6 +187,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # libpcap-dev: netscli-core pcap feature. GTK/WebKit: netscli-gui # (Tauri 2) — clippy --all-targets compiles the Tauri shell, which # won't link without these even when we only intend to lint Rust. @@ -247,6 +254,8 @@ jobs: os: [ubuntu-latest, macos-latest, windows-latest] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Linux needs libpcap for netscli-core's pcap feature and GTK/WebKit # for netscli-gui (Tauri 2). `cargo test --all` compiles the whole @@ -350,6 +359,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: # 22 — see the note on the maintainability job. This one is also @@ -406,6 +417,8 @@ jobs: os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "windows-latest", "macos-latest"]') }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install system dependencies (Linux) if: runner.os == 'Linux' run: | @@ -459,6 +472,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install system dependencies run: | # `|| true` so a broken third-party apt index cannot fail this job; diff --git a/.github/workflows/gui-render.yml b/.github/workflows/gui-render.yml index 04229234..a415ddfa 100644 --- a/.github/workflows/gui-render.yml +++ b/.github/workflows/gui-render.yml @@ -66,6 +66,8 @@ jobs: timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust # See ci.yml's lint job for why this pin keeps a `# stable` comment diff --git a/.github/workflows/installer-upgrade.yml b/.github/workflows/installer-upgrade.yml index 62446f22..8a6f028f 100644 --- a/.github/workflows/installer-upgrade.yml +++ b/.github/workflows/installer-upgrade.yml @@ -29,6 +29,8 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable diff --git a/.github/workflows/msstore.yml b/.github/workflows/msstore.yml index 1cb95661..1aa1cd56 100644 --- a/.github/workflows/msstore.yml +++ b/.github/workflows/msstore.yml @@ -30,6 +30,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Submit to the Store env: MSSTORE_TENANT_ID: ${{ secrets.MSSTORE_TENANT_ID }} diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 3cbb8ce7..07e8a81b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -55,6 +55,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: # Astro 6 requires Node >=22.12. Pinning to 22 (active LTS). diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4eea6d66..5819beee 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -72,6 +72,7 @@ jobs: # entirely, which landed after the tag it would first be used for. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.repository.default_branch }} - name: Promote the draft and start the pipeline diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3b09e0af..4f7606fc 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,6 +103,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TAG }} - name: Install Rust @@ -171,6 +172,7 @@ jobs: # published `netscli-win32-x64` again from the frozen copy. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.repository.default_branch }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -214,6 +216,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TAG }} - name: Update fstubner/homebrew-tap env: @@ -229,6 +232,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TAG }} - name: Update fstubner/scoop-bucket env: @@ -339,6 +343,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TAG }} # The tag reaches a `sed` replacement in the render step below, and @@ -450,6 +455,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TAG }} - name: Update fstubner/homebrew-tap (Cask) env: @@ -468,6 +474,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TAG }} - name: Update fstubner/scoop-bucket (GUI) env: @@ -573,6 +580,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ env.TAG }} # Same reasoning as the CLI AUR job: the tag reaches an inline `sed` @@ -682,6 +690,7 @@ jobs: # the binaries come from the release. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.repository.default_branch }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -728,6 +737,7 @@ jobs: # script and the server.json template, not anything that ships. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.repository.default_branch }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 102354a8..b7269e82 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -64,6 +64,7 @@ jobs: # `fetch-depth: 0` because a shallow clone carries no tag history. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + persist-credentials: false fetch-depth: 0 # Resolve the version from tags rather than letting release-drafter diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 80d217b1..f600f3cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,7 @@ jobs: # events the default ref is already the tag. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.inputs.tag || github.ref }} - name: Install Rust @@ -344,6 +345,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.inputs.tag || github.ref }} - name: Install Rust @@ -759,6 +761,7 @@ jobs: # without Windows binaries. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.repository.default_branch }} # libengine-pkcs11-openssl is not optional and not obvious. @@ -911,6 +914,7 @@ jobs: # from the tag below, because that is the one the shipped app holds. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false ref: ${{ github.event.repository.default_branch }} - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index 8ba09d86..c34af8b9 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -77,6 +77,8 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 63512886..5959dfbd 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -43,6 +43,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false fetch-depth: 0 - id: filter shell: bash @@ -77,6 +78,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version-file: .nvmrc @@ -128,6 +131,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + persist-credentials: false # Tags, not just the tip. `check:changelog` asks git which versions # are actually tagged so it can catch a changelog entry dated before # its release exists -- and with the default `fetch-depth: 1` there From 71d12935cfe23c1af9ec73a2724c471049230a62 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 18:45:25 +0100 Subject: [PATCH 02/23] ci: pin the publish dry run's toolchain action by commit dtolnay/rust-toolchain@stable was the last action reference on a moving branch. Pinned to the same commit every other job uses. Audit ci-release L-1. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d8f85fb..6785aee5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -482,7 +482,9 @@ jobs: sudo apt-get update || true sudo apt-get install -y libpcap-dev pkg-config - name: Install Rust - uses: dtolnay/rust-toolchain@stable + # See the pin note on the lint job above. This was the one `uses:` in + # the repository still on a moving ref. + uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: toolchain: "1.96.0" - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 From 0eb3061a73ae37e21126b591a3298b0d69c3f403 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 18:46:06 +0100 Subject: [PATCH 03/23] audit: drop cargo-audit ignores that no longer match anything Nine of the ignores named advisories that cannot fire against the current Cargo.lock, and an ignore that matches nothing would still hide the advisory if the crate came back: - paste (RUSTSEC-2024-0436), fxhash (RUSTSEC-2025-0057) and the five unic-* crates (RUSTSEC-2025-0075/0080/0081/0098/0100) are no longer in the lockfile. - RUSTSEC-2026-0097 was justified as rand 0.7.3 via kuchikiki. Neither is in the lockfile, and the rand versions that are (0.8.6, 0.10.1) are the patched ones. - RUSTSEC-2026-0002 (lru) is patched from 0.16.3, and the lockfile has lru 0.18.2. The proc-macro-error rationale named dialoguer, which does not pull it. glib-macros and gtk3-macros do. cargo audit --no-fetch (cargo-audit 0.22.2, local advisory db) reports the same as before the change: no vulnerabilities, 3 allowed warnings. Audit ci-release L-13. --- .cargo/audit.toml | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 227a72dd..ec974983 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -15,15 +15,6 @@ ignore = [ # -- Soundness warnings (transitive, not reachable via our usage) ----- - # rand 0.7.3 unsoundness with a custom logger calling rand::rng(). - # Transitive via Tauri's kuchikiki/selectors path; never invoked with - # a custom logger in our binaries. - "RUSTSEC-2026-0097", - - # lru IterMut stacked-borrows violation. Transitive via ratatui; we - # don't touch IterMut on lru caches in application code. - "RUSTSEC-2026-0002", - # glib VariantStrIter Iterator/DoubleEndedIterator unsoundness. # Transitive via Tauri 2; upstream Tauri is migrating to GTK4 which # replaces this binding stack. @@ -45,24 +36,9 @@ ignore = [ # -- Unmaintained: other transitive ------------------------------------ - # proc-macro-error: transitive via dialoguer and other proc-macro - # consumers. A maintained fork (proc-macro-error2) exists but not every - # upstream has migrated. No exploitable surface. + # proc-macro-error: transitive via glib-macros and gtk3-macros, the same + # Tauri 2 GTK3 stack as above. A maintained fork (proc-macro-error2) + # exists but those crates have not migrated. Compile-time only, no + # runtime surface. "RUSTSEC-2024-0370", - - # paste: unmaintained but still widely used. Transitive via several - # crates; a fork exists. No runtime surface beyond compile-time macros. - "RUSTSEC-2024-0436", - - # fxhash: transitive. rustc-hash is the maintained replacement but - # upstream deps still pin fxhash. - "RUSTSEC-2025-0057", - - # unic-* crates: transitive via idna via url via ipnet and hickory. - # Unmaintained for years; idna is migrating off the unic- stack. - "RUSTSEC-2025-0075", # unic-char-range - "RUSTSEC-2025-0080", # unic-common - "RUSTSEC-2025-0081", # unic-char-property - "RUSTSEC-2025-0098", # unic-ucd-version - "RUSTSEC-2025-0100", # unic-ucd-ident ] From 95ad4e47a9498f7ff4a4d759c15b0c382007d1e9 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 18:46:32 +0100 Subject: [PATCH 04/23] dependabot: cover the MCP smoke test and the Linux check image Two manifests had no update entry: - scripts/mcp-smoke (npm), the SDK-client smoke test for netscli serve. Keeping its SDK current is the point of the test. - scripts/linux-check.Dockerfile (docker). Its rust base image tracks rust-toolchain.toml, so a PR there is the one automatic signal that a new toolchain is out. The comment says what else has to move with it. packaging/npm/netscli/package.json stays out on purpose and now says why: it is a template full of @@VERSION@@ placeholders whose dependencies are this project's own packages. Audit ci-release I-3. --- .github/dependabot.yml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3ec90b0e..1e632411 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -97,6 +97,29 @@ updates: - "@astrojs/*" - "@astrojs/starlight" + # The MCP smoke test drives `netscli serve` with the official MCP SDK + # client, so it is a pinned npm project of its own. Its SDK version is the + # thing worth keeping current: the test exists because clients built on + # that SDK are the ones that dropped our replies. + - package-ecosystem: npm + directory: "/scripts/mcp-smoke" + schedule: + interval: monthly + open-pull-requests-limit: 3 + labels: + - dependencies + - javascript + - scripts + groups: + mcp-smoke: + patterns: + - "*" + + # packaging/npm/netscli/package.json is deliberately absent. It is a + # template: its version and its five optionalDependencies are @@VERSION@@ + # placeholders that publish-npm.sh fills in, and the packages it names are + # this project's own, published by the same job. + # scripts/ has its own Cargo project. - package-ecosystem: cargo directory: "/scripts" @@ -113,6 +136,20 @@ updates: - patch - minor + # scripts/linux-check.Dockerfile, the local Linux check image. Its only + # dependency is the rust base image, which is pinned to the toolchain in + # rust-toolchain.toml, so a PR here means a new Rust release is out. Bump + # rust-toolchain.toml, Cargo.toml's rust-version and the workflows' + # `toolchain:` inputs in the same change, or close it. + - package-ecosystem: docker + directory: "/scripts" + schedule: + interval: monthly + open-pull-requests-limit: 1 + labels: + - dependencies + - scripts + # GitHub Actions workflow pins. - package-ecosystem: github-actions directory: "/" From 3b54cf5ef4b5adb0ccf21f2bd6ebd3ab698bbe20 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 18:48:31 +0100 Subject: [PATCH 05/23] publish: submit to winget with a pinned, digest-checked Komac WINGET_TOKEN was handed to code nobody pinned. The SHA-pinned vedantmgoyal9/winget-releaser installs cargo-bins/cargo-binstall@main, uses it to install whichever komac is newest, and gives that binary the token, a classic PAT that can write to every repository its owner can. Both winget jobs now do what the action did, in three steps of their own: download komac 2.16.0's Windows binary and refuse it unless its SHA-256 matches the value pinned at the top of the workflow (taken from Komac's SHA256SUMS on the v2.16.0 release, which the GitHub API's asset digest agrees with), run komac update --submit for the same identifier and installer URL as before, then komac cleanup --only-merged. The token is in the environment of the last two steps only, never on a command line. Komac 2.16.0 derives the fork from the token's owner, so moving the token to a bot account needs no workflow change. Audit H5 (ci-release H-1). --- .github/workflows/publish.yml | 163 +++++++++++++++++++++++----------- 1 file changed, 110 insertions(+), 53 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4f7606fc..db5d01fb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -70,6 +70,15 @@ permissions: # version regardless of trigger. env: TAG: ${{ github.event.inputs.tag || github.ref_name }} + # Komac, which writes the winget manifests and opens the pull request in + # the two winget jobs. Pinned to one release binary AND its SHA-256, + # because that binary is handed WINGET_TOKEN. The digest is the + # komac-2.16.0-x86_64-pc-windows-msvc.exe line of the SHA256SUMS file on + # https://github.com/russellbanks/Komac/releases/tag/v2.16.0, and the + # GitHub API's own digest for that asset agrees. Bump both lines together, + # from the same two sources. + KOMAC_VERSION: 2.16.0 + KOMAC_SHA256: bdc45baf028f750da7519cc8a5f2eab5dfef46dfe88650eeb0d0a9bf51446e4f jobs: # Every job below sets `timeout-minutes`; GitHub's default is 360. @@ -240,25 +249,37 @@ jobs: run: bash scripts/release/publish-scoop.sh "$TAG" # ─── Winget (microsoft/winget-pkgs) ──────────────────────────────── - # Forks microsoft/winget-pkgs under the user's account, generates a - # 0.X.Y/ manifest folder from the release, opens a PR. Moderator - # approval lands it on the public catalog. + # Komac reads the package's latest manifests from microsoft/winget-pkgs, + # writes the new version's from the release's installer, and opens a PR + # from the token owner's fork. Moderator approval lands it on the public + # catalog. + # + # This used to be vedantmgoyal9/winget-releaser, and its SHA pin did not + # pin what got the token. At the pinned commit the action installs + # `cargo-bins/cargo-binstall@main`, a moving branch, uses it to install + # whichever `komac` is newest, and hands that binary WINGET_TOKEN, a + # classic PAT that can write to every repository its owner can. The steps + # below do what the action did -- install Komac, `komac update --submit`, + # `komac cleanup --only-merged` -- with Komac pinned to one binary and its + # digest (KOMAC_* at the top of this file), and the token in the + # environment of the two steps that use it and of nothing else. + # + # Re-running it does not open a second PR. Komac looks for an existing PR + # for the same package and version first, and in CI it stops there rather + # than asking (src/commands/utils/mod.rs, `prompt_existing_pull_request`, + # at v2.16.0). winget: name: Winget if: inputs.only == '' || inputs.only == 'winget' runs-on: windows-latest timeout-minutes: 30 steps: - # The `winget-releaser` action fetches the release's asset list - # via the GitHub API and bails fast if the matching .exe is not - # present yet. Both publish.yml and release.yml fire on - # `release: published`, so without polling here we race - # release.yml's Windows runner: in v0.2.5's run the action ran - # at ~1m45s but the .exe didn't land until ~7m later, giving - # the empty-`--urls` failure that needed a manual rerun. Poll - # the .sha256 sidecar (uploaded last in the matrix; ~600 bytes, - # cheap to HEAD) before invoking. Modeled on the AUR job's - # equivalent step further down. + # The release's assets exist before publish-release.yml makes it + # public, so on that path this passes at once. It is kept for a release + # promoted by hand while its build is still running, which is what + # v0.2.5 hit: the job ran at ~1m45s, the .exe landed ~7m later, and + # the submission failed. Polls the .sha256 sidecar, ~600 bytes and + # cheap to HEAD, as the AUR job's `verified_sha` does. - name: Wait for Windows CLI release asset shell: bash env: @@ -279,15 +300,9 @@ jobs: exit 1 # `version` is the PackageVersion that lands in the catalog, and it must - # not carry the tag's `v`. The action strips it only when the input is - # empty -- its own source reads - # - # If ('' -eq '${{ inputs.version }}') { tag_name -replace '^v' } - # Else { inputs.version } - # - # -- so passing `${{ env.TAG }}` opted out of the stripping and would - # have submitted `v0.3.1` beside the existing `0.3.0`. Winget sorts - # versions naturally, so the two do not compare as an upgrade. + # not carry the tag's `v`. Komac takes it exactly as given, so passing + # the tag would submit `v0.3.1` beside the existing `0.3.0`. Winget + # sorts versions naturally, so the two do not compare as an upgrade. - name: Resolve PackageVersion (tag without the leading v) id: pkg shell: bash @@ -316,20 +331,44 @@ jobs: echo "→ PackageVersion ${version}" echo "version=${version}" >> "$GITHUB_OUTPUT" + # No token here. This step only downloads, and the download has to + # match the pinned digest before anything runs it. + - name: Install Komac + shell: pwsh + run: | + $url = "https://github.com/russellbanks/Komac/releases/download/v$env:KOMAC_VERSION/komac-$env:KOMAC_VERSION-x86_64-pc-windows-msvc.exe" + $komac = Join-Path $env:RUNNER_TEMP 'komac.exe' + Invoke-WebRequest -Uri $url -OutFile $komac + $got = (Get-FileHash -Algorithm SHA256 -LiteralPath $komac).Hash.ToLowerInvariant() + if ($got -ne $env:KOMAC_SHA256) { + Write-Error "komac.exe digest $got, expected $env:KOMAC_SHA256" + exit 1 + } + & $komac --version + "KOMAC=$komac" >> $env:GITHUB_ENV + + # Komac reads the token from GITHUB_TOKEN, so it never reaches a + # command line. It downloads the installer from the URL itself and + # hashes it, which is what the manifest's InstallerSha256 comes from. - name: Submit netscli to microsoft/winget-pkgs - uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 - with: - identifier: fstubner.netscli - version: ${{ steps.pkg.outputs.version }} - # `release-tag` defaults to - # `github.event.release.tag_name || github.ref_name`, and on a - # `workflow_dispatch` the first is null and the second is the branch - # the run was dispatched from. The action would then ask the API for - # a release tagged `main` and fail -- on precisely the re-run path - # this workflow's header offers for recovering a failed publish. - release-tag: ${{ env.TAG }} - installers-regex: 'netscli-windows-x86_64\.exe$' - token: ${{ secrets.WINGET_TOKEN }} + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }} + VERSION: ${{ steps.pkg.outputs.version }} + run: | + $url = "https://github.com/fstubner/netscli/releases/download/$env:TAG/netscli-windows-x86_64.exe" + & $env:KOMAC update fstubner.netscli --version $env:VERSION --urls $url --submit + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + # What the action did after submitting: delete the fork's branches whose + # PRs have merged, so the fork does not collect one per release. + - name: Delete merged branches from the winget-pkgs fork + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + & $env:KOMAC cleanup --only-merged --all + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # ─── AUR (netscli-bin) ───────────────────────────────────────────── # Pre-step computes SHA256s from release assets and renders a fresh @@ -502,14 +541,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - # Same wait-then-submit pattern as the CLI Winget job above. The - # GUI .msi takes longer to build than the CLI .exe (Tauri bundle - # + WiX compose), so this poll matters even more here. - # - # This used to warn that the job would fail until winget-pkgs had - # accepted a first version of `fstubner.netscli.gui`, since the action - # looks for an existing package directory before doing anything. That - # was resolved when PR #368471 landed 0.2.6; the directory exists. + # Same wait-then-submit pattern as the CLI Winget job above. `komac + # update` only updates a package winget-pkgs already has, which + # `fstubner.netscli.gui` has been since PR #368471 landed 0.2.6. - name: Wait for Windows GUI release asset shell: bash env: @@ -529,9 +563,8 @@ jobs: echo "asset never appeared after 15 minutes" >&2 exit 1 - # Same two corrections as the CLI winget job above: strip the `v` from - # the PackageVersion, and name the release tag explicitly so a - # `workflow_dispatch` re-run does not look for a release tagged `main`. + # Same correction as the CLI winget job above: no `v` in the + # PackageVersion. - name: Resolve PackageVersion (tag without the leading v) id: pkg shell: bash @@ -560,14 +593,38 @@ jobs: echo "→ PackageVersion ${version}" echo "version=${version}" >> "$GITHUB_OUTPUT" + # The same three steps as the CLI winget job above, for the MSI. + - name: Install Komac + shell: pwsh + run: | + $url = "https://github.com/russellbanks/Komac/releases/download/v$env:KOMAC_VERSION/komac-$env:KOMAC_VERSION-x86_64-pc-windows-msvc.exe" + $komac = Join-Path $env:RUNNER_TEMP 'komac.exe' + Invoke-WebRequest -Uri $url -OutFile $komac + $got = (Get-FileHash -Algorithm SHA256 -LiteralPath $komac).Hash.ToLowerInvariant() + if ($got -ne $env:KOMAC_SHA256) { + Write-Error "komac.exe digest $got, expected $env:KOMAC_SHA256" + exit 1 + } + & $komac --version + "KOMAC=$komac" >> $env:GITHUB_ENV + - name: Submit netscli-gui to microsoft/winget-pkgs - uses: vedantmgoyal9/winget-releaser@4ffc7888bffd451b357355dc214d43bb9f23917e # v2 - with: - identifier: fstubner.netscli.gui - version: ${{ steps.pkg.outputs.version }} - release-tag: ${{ env.TAG }} - installers-regex: 'netscli-gui-windows-x86_64\.msi$' - token: ${{ secrets.WINGET_TOKEN }} + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }} + VERSION: ${{ steps.pkg.outputs.version }} + run: | + $url = "https://github.com/fstubner/netscli/releases/download/$env:TAG/netscli-gui-windows-x86_64.msi" + & $env:KOMAC update fstubner.netscli.gui --version $env:VERSION --urls $url --submit + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Delete merged branches from the winget-pkgs fork + shell: pwsh + env: + GITHUB_TOKEN: ${{ secrets.WINGET_TOKEN }} + run: | + & $env:KOMAC cleanup --only-merged --all + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } # ─── AUR (netscli-gui-bin) ───────────────────────────────────────── # Sibling to the aur job above. Same SSH key + same render-then-push From 0f4a772f126cb9b2fb06e6502fe6d8d9e6100e3e Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 18:57:30 +0100 Subject: [PATCH 06/23] release: build into the draft, check every asset, then publish A release went public first and was built afterwards. v0.3.4 had no Windows installer and no latest.json for 1 h 38 min after it was published, so install.ps1, the in-app updater and the package managers that had already published pointed at files that did not exist. v0.3.3's Windows files were 1 h 06 min late. publish-release.yml now runs the whole sequence as three jobs: - prepare: tag well formed and pushed, run from main, release still a draft, CHANGELOG.md has the version's section. - build: calls release.yml (now also a reusable workflow) against the still-draft release. - promote: scripts/release/check-release-assets.mjs requires all 18 files with their .sha256/.sig/.pem, every .sha256 equal to the digest GitHub computed for the upload, and a latest.json for this version pointing at this tag. Only then are the notes written, the draft made public and read back, and publish.yml and pages.yml dispatched. The lock against release-drafter is unchanged and is held for the whole run. release.yml: - Drops the `release: published` trigger, so promoting the draft cannot start a second build that would replace published assets with new digests. A new guard job refuses a malformed tag, a run off main, and a release that is already public, so a manual dispatch cannot clobber a published release either. - Uploads with `gh release upload --clobber` instead of softprops/action-gh-release, which PATCHes the release on every upload and, with `draft` unset, publishes the draft once its files are up. - sign-windows runs when a Linux or macOS leg failed (!cancelled()), so one attempt surfaces Windows signing problems too. It still requires all three Windows files. updater-manifest gets an explicit condition so a failed CLI leg does not skip it through the needs chain. Re-runs: a failed leg is re-run from the publish-release run ("Re-run failed jobs"), which re-runs everything after it, promote included. A re-run that finds the release already public stops with the commands to finish by hand, so the fan-out never runs twice. The new check has tests (node --test, wired into CI's script-lint job). Against the real v0.3.4 asset list it passes. With v0.3.4's Windows files and latest.json removed, as they were for 1 h 38 min, it fails naming all 13 missing files. A sabotaged copy that accepts everything fails 10 of the 13 tests. Audit H6 (ci-release H-2), ci-release L-2. --- .github/workflows/ci.yml | 8 + .github/workflows/publish-release.yml | 211 ++++++++++++++---- .github/workflows/publish.yml | 24 +- .github/workflows/release.yml | 210 ++++++++++++----- scripts/release/check-release-assets.mjs | 181 +++++++++++++++ scripts/release/check-release-assets.test.mjs | 129 +++++++++++ scripts/release/lib.sh | 9 +- 7 files changed, 658 insertions(+), 114 deletions(-) create mode 100644 scripts/release/check-release-assets.mjs create mode 100644 scripts/release/check-release-assets.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6785aee5..6a9dbcb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,6 +162,14 @@ jobs: # reach, so its test answers in the API's place. - name: test Store submission script run: bash scripts/release/msstore-submit_test.sh + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + # The last check before a release goes public, and the builder of the + # in-app updater's latest.json. Both run only during a release, and a + # mistake in the first one would let an incomplete release out. + - name: test release asset and updater manifest checks + run: node --test scripts/release/*.test.mjs - name: PSScriptAnalyzer install.ps1 shell: pwsh # Write-Host is intentional here (colored installer UX output, which diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 5819beee..4464ae7f 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -1,15 +1,34 @@ name: Publish release -# Promotes a drafted GitHub release to public. +# Builds a drafted release, checks it is complete, and only then makes it +# public and starts the publishing. # -# Publishing does not need a workflow. This one exists to hold a lock. +# gh workflow run publish-release.yml -f tag=vX.Y.Z # -# release-drafter rewrites its draft by listing every release, taking the -# first one whose `draft` is true, and then PATCHing that release by id. It -# re-checks nothing between the read and the write, and the payload it sends -# always carries `draft: true` (see `find-previous-releases.ts` and -# `update-release.ts` at the SHA pinned in release-drafter.yml). Publish -# inside that gap and the PATCH lands on a release that is no longer a draft. +# In order, as jobs: +# +# prepare the tag exists, the release is a draft, and CHANGELOG.md has +# the version's section +# build release.yml, called here, builds and signs every asset and +# attaches it to the DRAFT +# promote checks the draft carries every asset, writes the notes, makes +# the release public, then starts publish.yml (package managers) +# and pages.yml (netscli.com and its Store MSI copies) +# +# The release used to go public first and be built afterwards. v0.3.4 had +# no Windows installer and no latest.json for 1 h 38 min after it was +# published, and install.ps1, the in-app updater and every package manager +# that had already published pointed at files that did not exist. Now a +# failed build leaves a draft nobody can see. Fix it and re-run, see +# docs/RELEASE.md. +# +# It also holds a lock. release-drafter rewrites its draft by listing every +# release, taking the first one whose `draft` is true, and then PATCHing +# that release by id. It re-checks nothing between the read and the write, +# and the payload it sends always carries `draft: true` (see +# `find-previous-releases.ts` and `update-release.ts` at the SHA pinned in +# release-drafter.yml). Publish inside that gap and the PATCH lands on a +# release that is no longer a draft. # # Not theoretical. Drafter run 34597984827 built its payload at 12:15:46.96 # and logged "Release updated!" at 12:15:47.96; v0.3.1 was published at @@ -32,34 +51,42 @@ on: workflow_dispatch: inputs: tag: - description: 'Tag of the drafted release to publish (e.g. v0.3.1)' + description: 'Tag of the drafted release to build and publish (e.g. v0.3.1)' required: true type: string -# `contents: write` to flip the draft. `actions: write` to dispatch the two -# workflows at the end -- nothing here needs more. +# Each job asks for what it needs. permissions: - contents: write - actions: write + contents: read -# Shared with release-drafter.yml. This group is the entire point of the -# workflow; queueing behind an in-flight drafter run costs about 30 seconds. +# Shared with release-drafter.yml, and held for the whole run, build +# included. Draft rewrites wait until the release is public, which is what +# the lock is for. The one cost is GitHub's rule for a busy group: it keeps +# one waiting run and cancels the one that was waiting before it. A publish +# dispatched while a drafter run is in progress waits, and is cancelled if +# another push to main lands in those ~30 seconds. It is cancelled before +# it has done anything, so dispatch it again. # # `cancel-in-progress: false` on BOTH sides, and that pairing is load-bearing # rather than cautious. The setting is read from the workflow of the run that # joins the group, so a drafter configured to cancel would cancel a publish -# run already in progress -- leaving the release public with neither -# release.yml nor publish.yml ever dispatched. Serialising instead costs a -# few idle seconds. +# run already in progress -- possibly between making the release public and +# starting the package managers. Serialising instead costs a few idle +# seconds. concurrency: group: release-draft cancel-in-progress: false jobs: - publish: - name: Promote draft to public + # Everything that can be wrong before spending 40 minutes on a build. + prepare: + name: Check the tag and the draft runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 5 + permissions: + # Write, for reads. GitHub shows a draft release only to a token that + # can push, so with `contents: read` the draft would look missing. + contents: write steps: # For scripts/release-notes.mjs and the two files it reads. # @@ -75,11 +102,12 @@ jobs: persist-credentials: false ref: ${{ github.event.repository.default_branch }} - - name: Promote the draft and start the pipeline + - name: Check the tag, the draft and the changelog env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} TAG: ${{ inputs.tag }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} shell: bash run: | set -euo pipefail @@ -91,17 +119,110 @@ jobs: exit 1 fi + # The signing jobs use the release-signing environment, which only + # admits deployments from main and v* tags. From any other branch + # the build would run for a quarter of an hour and then be refused. + if [[ "$GITHUB_REF" != "refs/heads/${DEFAULT_BRANCH}" ]]; then + echo "::error::dispatch this from ${DEFAULT_BRANCH}, not ${GITHUB_REF}." + exit 1 + fi + if ! gh api "repos/${GH_REPO}/git/ref/tags/${TAG}" >/dev/null 2>&1; then echo "::error::no tag ${TAG} in this repository -- tag and push it first." exit 1 fi if ! is_draft=$(gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/dev/null); then - echo "::error::no release found for ${TAG}. Draft it first." + echo "::error::no release found for ${TAG}. release-drafter makes the draft; run it from the Actions tab." exit 1 fi if [[ "$is_draft" != "true" ]]; then - echo "::error::${TAG} is already published. Nothing to promote." + echo "::error::${TAG} is already published. Nothing to build or promote." + exit 1 + fi + + # Fails when CHANGELOG.md has no section for the version. Checked + # here so that it fails before the build rather than after it. The + # notes are written for real just before promoting. + node scripts/release-notes.mjs "$TAG" > /dev/null + echo "${TAG} is a draft with a changelog section; building it." + + # The whole build, as release.yml runs it, against the draft. Its jobs show + # up in this run as "Build / ...", and "Re-run failed jobs" here re-runs a + # failed leg and everything after it, including `promote`. + # + # The permissions are the ceiling for every job release.yml runs. The + # signing secrets come from the release-signing environment, which those + # jobs name themselves, so nothing is passed down. + build: + name: Build + needs: prepare + permissions: + contents: write + id-token: write + uses: ./.github/workflows/release.yml + with: + tag: ${{ inputs.tag }} + + promote: + name: Check the assets and publish + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # `contents: write` to read the draft's assets and flip it. `actions: + # write` to start the two workflows at the end. + contents: write + actions: write + steps: + # The default branch, for the same reason `prepare` gives. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.event.repository.default_branch }} + + # The list, the 18 .sha256 files and latest.json are fetched here, and + # scripts/release/check-release-assets.mjs decides. It needs every + # file, sidecar and signature, every checksum to match the uploaded + # file, and a latest.json for this version. + - name: Check every asset is on the draft + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + work="${RUNNER_TEMP}/assets" + mkdir -p "${work}/sidecars" + # The draft's id, from the API URL `gh` reports for it. The assets + # endpoint carries the digest GitHub computed for each upload. + api_url=$(gh release view "$TAG" --json apiUrl --jq '.apiUrl') + gh api --paginate "repos/${GH_REPO}/releases/${api_url##*/}/assets?per_page=100" \ + --jq '.[] | {name, size, digest}' > "${work}/assets.jsonl" + # Either download fails when nothing matches. The check below then + # reports exactly which files are missing. + gh release download "$TAG" --pattern '*.sha256' --dir "${work}/sidecars" || true + gh release download "$TAG" --pattern 'latest.json' --dir "${work}" || true + node scripts/release/check-release-assets.mjs "$TAG" \ + "${work}/assets.jsonl" "${work}/sidecars" "${work}/latest.json" + + - name: Promote the draft and start the publishing + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.tag }} + shell: bash + run: | + set -euo pipefail + + # Only a draft is promoted here. A re-run that finds the release + # already public stops, because the run that promoted it may already + # have started publish.yml, and a second fan-out would try every + # registry again. The message says how to finish by hand. + is_draft=$(gh release view "$TAG" --json isDraft --jq '.isDraft') + if [[ "$is_draft" != "true" ]]; then + echo "::error::${TAG} is already public. If publish.yml or pages.yml did not start, run them by hand: gh workflow run publish.yml -f tag=${TAG} and gh workflow run pages.yml" exit 1 fi @@ -114,12 +235,7 @@ jobs: # "merge the changelog, then publish" is the ordinary sequence. On # 0.3.2 two PRs landed in that gap and the hand-written overview was # gone, with nothing reporting it. Inside the concurrency group this - # job already holds, the drafter cannot be mid-rewrite here. - # - # Failing here stops the publish, which is the right way round: a - # release whose notes cannot be built is one whose CHANGELOG section - # is missing, and that is worth knowing before it is public rather - # than after. + # run already holds, the drafter cannot be mid-rewrite here. echo "Composing release notes for ${TAG}..." node scripts/release-notes.mjs "$TAG" > "${RUNNER_TEMP}/release-notes.md" gh release edit "$TAG" --notes-file "${RUNNER_TEMP}/release-notes.md" @@ -128,33 +244,40 @@ jobs: gh release edit "$TAG" --draft=false # Read it back rather than assume the edit did what it said. This is - # the exact failure this workflow exists for, so it is worth one API - # call to prove the release is public AND still carries its tag. + # the exact failure the lock exists for, so it is worth one API call + # to prove the release is public AND still carries its tag. after=$(gh release view "$TAG" --json isDraft,tagName --jq '"\(.isDraft) \(.tagName)"') if [[ "$after" != "false ${TAG}" ]]; then - echo "::error::${TAG} did not survive the promote (isDraft/tagName = ${after}). Check for a concurrent release-drafter run." + echo "::error::${TAG} did not survive the promote (isDraft/tagName = ${after}). Check for a concurrent release-drafter run, repair the release by hand, then run publish.yml and pages.yml." exit 1 fi # `release: published` does NOT fire here -- GitHub suppresses # events raised by GITHUB_TOKEN. `workflow_dispatch` is one of its # two documented exceptions, so the consumers are started - # explicitly instead. + # explicitly. # - # This is more deterministic than the event path it replaces, where - # release.yml and publish.yml both fire on `release: published` at - # the same instant and race. Order here is deliberate but not - # blocking: publish.yml's `wait_for_asset` polls for 15 minutes, so - # it tolerates release.yml still building, exactly as before. - gh workflow run release.yml -f tag="$TAG" - gh workflow run publish.yml -f tag="$TAG" + # publish.yml first: it is the one with users waiting on it. Every + # asset it needs is already on the release, so its jobs publish + # straight away instead of polling for builds. + if ! gh workflow run publish.yml -f tag="$TAG"; then + echo "::error::${TAG} is public, but publish.yml did not start. Run: gh workflow run publish.yml -f tag=${TAG} and gh workflow run pages.yml" + exit 1 + fi + # pages.yml rebuilds netscli.com, which shows the newest published + # version, and copies this release's MSI to the address the + # Microsoft Store downloads it from. It deploys on pushes to main + # too, but nothing in a release pushes to main. + if ! gh workflow run pages.yml; then + echo "::error::${TAG} is public and publish.yml started, but pages.yml did not. Run: gh workflow run pages.yml" + exit 1 + fi { echo "### Published ${TAG}" echo + echo "- Every asset was on the draft and checked before it went public." echo "- Release is public and still tagged \`${TAG}\`." - echo "- Dispatched \`release.yml\` (binaries + GUI installers)." echo "- Dispatched \`publish.yml\` (package managers)." - echo - echo "Watch both in the Actions tab. Assets attach to the release as they build." + echo "- Dispatched \`pages.yml\` (netscli.com and the Store's MSI copy)." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index db5d01fb..a35a755e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,11 +8,13 @@ name: Publish to package managers # # Triggers: # - On `workflow_dispatch` with a tag input. The canonical path: -# publish-release.yml dispatches this after promoting a draft, and it -# is also how you re-run a specific job after a transient failure. +# publish-release.yml dispatches this after promoting a draft whose +# assets it has already checked, and it is also how you re-run a +# specific job after a transient failure. # - On `release: published`, still honoured for a release promoted by -# hand. Note that promoting by hand races release-drafter -- see -# publish-release.yml for why that path is no longer recommended. +# hand. Note that promoting by hand races release-drafter, and skips +# the build and the asset check -- see publish-release.yml for why that +# path is no longer recommended. on: release: @@ -83,12 +85,14 @@ env: jobs: # Every job below sets `timeout-minutes`; GitHub's default is 360. # - # These are sized differently from ci.yml, and the reason matters. These - # jobs fire on `release: published` at the same moment release.yml starts - # building, so a publish job legitimately spends most of its life *waiting* - # for the asset it needs. `wait_for_asset` in scripts/release/lib.sh bounds - # that itself: 30 polls at 30s, so 15 minutes, and then it fails with a - # message naming the URL that never appeared. + # These are sized differently from ci.yml, and the reason matters. On the + # canonical path every asset is on the release before this workflow + # starts, and the waits below pass at once. A release promoted by hand + # can start these jobs before all its assets are there, and then a job + # spends most of its life *waiting* for the asset it needs. + # `wait_for_asset` in scripts/release/lib.sh bounds that itself: 30 polls + # at 30s, so 15 minutes, and then it fails with a message naming the URL + # that never appeared. # # That graceful failure is worth protecting. A job timeout below ~15 # minutes would pre-empt it and replace a clear explanation with an opaque diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f600f3cf..209eee0b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,46 +1,113 @@ name: Release +# Builds every release asset and attaches it to the DRAFT release for a tag. +# +# publish-release.yml calls this while the release is still a draft, waits +# for it, checks the assets, and only then makes the release public. It +# used to be the other way round: the release went public first and was +# built afterwards. v0.3.4 had no Windows installer and no latest.json for +# 1 h 38 min after it was published, and in that window install.ps1, the +# in-app updater and every package manager that had already published +# pointed at files that did not exist. v0.3.3's Windows files were 1 h 06 +# min late. +# +# There is deliberately no `release: published` trigger any more. Promoting +# the draft is the last step, after this has finished, and a build started +# by it would replace a public release's assets with new builds, whose +# digests no longer match what the package managers just published. +# +# `workflow_dispatch` stays, for rebuilding a draft's assets by hand. The +# `guard` job refuses a release that is already public, for the same +# reason. on: - release: - # `published` fires when a draft transitions to public. `created` only - # fires on initial draft creation, which happens via release-drafter - # (a GITHUB_TOKEN-driven action) so wouldn't trigger us anyway. - types: [published] + workflow_call: + inputs: + tag: + description: 'Tag of the draft release to build and upload assets for' + required: true + type: string workflow_dispatch: inputs: tag: - description: 'Tag to build and upload assets for (e.g. v0.1.0)' + description: 'Tag of the DRAFT release to build and upload assets for (e.g. v0.1.0)' required: true type: string -# Keyed on the tag, not the ref: `workflow_dispatch` and `release: published` -# reach the same tag by different refs, and two runs uploading assets for one -# tag race each other on `softprops/action-gh-release`. +# Keyed on the tag. Two runs uploading assets for one tag would race each +# other, and the second would replace half of the first's files. # # `cancel-in-progress: false` deliberately, unlike ci.yml/site.yml. A cancelled -# matrix leg leaves the release with some assets uploaded and some missing, -# and publish.yml is meanwhile polling for exactly the assets that will now -# never arrive. Queueing a duplicate run is harmless — it rebuilds and -# re-uploads the same files. +# matrix leg leaves the draft with some assets uploaded and some missing. +# Nothing public depends on a draft, but a queued duplicate costs only time: +# it rebuilds and re-uploads every file with --clobber. concurrency: - group: release-${{ github.event.inputs.tag || github.ref_name }} + group: release-${{ inputs.tag }} cancel-in-progress: false +# `inputs` carries the tag on both triggers. +env: + TAG: ${{ inputs.tag }} + jobs: + # Every job below needs this one, so nothing builds, signs or uploads + # unless the tag is well formed, the run is on the default branch, and the + # release is still a draft. + # + # The default branch, because the `release-signing` environment the + # signing jobs use allows deployments from `main` and `v*` tags only. A run + # from anywhere else would build for 15 minutes and then be refused at the + # first signing job. + guard: + name: Check the release is a draft + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + # Write, for a read. GitHub shows draft releases only to a token that + # can push, so with `contents: read` the draft would look missing. + contents: write + steps: + - name: Refuse a malformed tag, another branch, or a public release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: | + set -euo pipefail + # The same shape lib.sh's validate_tag accepts. The tag reaches + # checkout refs, asset URLs and `gh` arguments below. + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::refusing to build malformed tag '$TAG'" + exit 1 + fi + if [[ "$GITHUB_REF" != "refs/heads/${DEFAULT_BRANCH}" ]]; then + echo "::error::run this from ${DEFAULT_BRANCH}, not ${GITHUB_REF}. The signing jobs may only deploy from ${DEFAULT_BRANCH}." + exit 1 + fi + if ! is_draft=$(gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/dev/null); then + echo "::error::no release found for ${TAG}. release-drafter makes the draft; run it or draft one by hand." + exit 1 + fi + if [[ "$is_draft" != "true" ]]; then + echo "::error::${TAG} is already public. Rebuilding would change every digest the package managers have published." + exit 1 + fi + echo "${TAG} is a draft; building its assets." + # Both jobs below set `timeout-minutes`; GitHub's default is 360. A stalled # `apt-get` once held CI jobs at that default for six hours (see ci.yml), # and these run the same system-dependency installs. # - # A timeout here is safe to recover from: the job uploads assets to an - # existing release, so a missed asset is re-uploaded by re-running the job. - # Downstream, publish.yml's `wait_for_asset` fails with a clear message - # rather than publishing a manifest for something that is not there. + # A timeout here is safe to recover from: the job uploads to a release that + # is still a draft, so a missing asset is re-uploaded by re-running the job, + # and publish-release.yml will not make the release public until every + # asset is there. # # Sized against measured runs: the slowest observed job is the Windows GUI # installer at ~7 minutes, so 60 leaves ample room for a cold cache, # cross-compilation and bundling while still bounding a genuine hang. release: name: Build and Release + needs: guard runs-on: ${{ matrix.os }} timeout-minutes: 60 permissions: @@ -121,12 +188,11 @@ jobs: build_flags: --features pcap pcap: true steps: - # On workflow_dispatch we need the tag ref explicitly. On release - # events the default ref is already the tag. + # The tag, explicitly. The run itself is on the default branch. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ env.TAG }} - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -199,9 +265,9 @@ jobs: # ever downloads. So the Windows legs upload the unsigned binary as a # workflow artifact instead, and `sign-windows` does the rest. # - # It also means an unsigned .exe is never a release asset, not even - # briefly: release.yml runs after the draft has been promoted, so - # anything uploaded here is immediately public. + # It also means an unsigned .exe is never a release asset, not even on + # the draft. A draft can still be published by hand from the web UI, + # and whatever it carries at that moment goes public with it. - name: Hand the unsigned binary to the signing job if: runner.os == 'Windows' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -214,9 +280,14 @@ jobs: # Sigstore keyless signing via the GitHub Actions OIDC token. Produces # a .sig and .pem alongside each binary; verifiable by anyone with: # cosign verify-blob --signature .sig --certificate .pem \ - # --certificate-identity-regexp 'https://github.com/fstubner/netscli/.github/workflows/release\.yml@.*' \ + # --certificate-identity-regexp '^https://github\.com/fstubner/netscli/\.github/workflows/release\.yml@refs/(heads/main|tags/v[0-9.]+)$' \ # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ # + # The identity is this file on main (or, before v0.3.1, on the release + # tag), also when publish-release.yml calls it: Fulcio names the called + # workflow, not the caller. A looser `release\.yml@.*` would accept a + # signature made by this file on any branch anyone with write access + # can push. - name: Install cosign if: runner.os != 'Windows' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -279,18 +350,22 @@ jobs: --output-certificate "${{ matrix.asset_name }}.pem" \ "${{ matrix.asset_name }}" + # `gh release upload`, not softprops/action-gh-release. That action + # PATCHes the release on every upload, rewriting its name, body and + # draft flag from what it read a moment earlier, and with `draft` + # unset it publishes the draft once its files are up. Either is wrong + # for a release that must stay a draft until publish-release.yml has + # checked every asset. `gh` finds the draft by its tag, uploads, and + # touches nothing else. `--clobber` so a re-run replaces its own files. - name: Upload Release Asset if: runner.os != 'Windows' - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 - with: - files: | - target/${{ matrix.target }}/release/${{ matrix.asset_name }} - target/${{ matrix.target }}/release/${{ matrix.asset_name }}.sha256 - target/${{ matrix.target }}/release/${{ matrix.asset_name }}.sig - target/${{ matrix.target }}/release/${{ matrix.asset_name }}.pem - tag_name: ${{ github.event.inputs.tag || github.ref_name }} + shell: bash + working-directory: target/${{ matrix.target }}/release env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + ASSET: ${{ matrix.asset_name }} + run: gh release upload "$TAG" "$ASSET" "$ASSET.sha256" "$ASSET.sig" "$ASSET.pem" --clobber # ─── Desktop GUI installers ─────────────────────────────────────────── # Builds the Tauri 2 desktop app on each native runner. .msi/.dmg/.deb/ @@ -309,11 +384,13 @@ jobs: # are out of matrix scope for v0.2.1 to keep CI budget reasonable. gui: name: Build GUI installer + needs: guard runs-on: ${{ matrix.os }} timeout-minutes: 60 - # For TAURI_SIGNING_PRIVATE_KEY, the in-app updater's signing key. Same - # environment as sign-windows, no protection rules, for the same reason: - # it scopes the secret rather than gating the job. + # For TAURI_SIGNING_PRIVATE_KEY, the in-app updater's signing key, and on + # Windows the Certum credentials. Same environment as sign-windows. It + # admits deployments from `main` and `v*` tags only, which the `guard` + # job checks before anything is built. environment: release-signing permissions: contents: write @@ -346,7 +423,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ github.event.inputs.tag || github.ref }} + ref: ${{ env.TAG }} - name: Install Rust uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable @@ -706,15 +783,15 @@ jobs: "$f" done + # `gh`, for the reasons given at the CLI job's upload. - name: Upload Release Asset if: runner.os != 'Windows' - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 - with: - files: | - ${{ runner.temp }}/gui-out/${{ matrix.asset_prefix }}.* - tag_name: ${{ github.event.inputs.tag || github.ref_name }} + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + ASSET_PREFIX: ${{ matrix.asset_prefix }} + run: gh release upload "$TAG" "${RUNNER_TEMP}/gui-out/${ASSET_PREFIX}".* --clobber # ─── Authenticode signing for the Windows artifacts ─────────────────── # Every Windows artifact is signed here, and nowhere else. The two matrix @@ -733,13 +810,24 @@ jobs: # so the runner's OS stops mattering. See scripts/release/sign-windows.sh. # # `environment: release-signing` scopes the credentials to this job. The - # environment deliberately carries no protection rules: a required - # reviewer or a branch policy here would stall every release at the point - # where the artifacts are half uploaded. It exists to hold the secrets, - # not to gate anything. + # environment admits deployments from `main` and `v*` tags only, and has + # no required reviewers. A reviewer would hold the release at this job, + # but on a draft that holds nothing public, so it is possible now that the + # release is built before it is published. Not added, because a stolen + # maintainer token can approve its own deployment through the API. + # + # It waits for every build leg but no longer needs them all to succeed. + # `!cancelled()` lets it run when a Linux or macOS leg failed. Before, one + # failed leg anywhere skipped Windows signing, the step that broke the + # last two releases, so its own problems only showed up on the re-run. + # Now one attempt reports both, and everything that did build lands on + # the draft. The Windows legs are still required: "Check what arrived" + # fails unless all three Windows files are here. `guard` must have + # passed, or nothing was built and there is nothing to sign. sign-windows: name: Sign Windows artifacts - needs: [release, gui] + needs: [guard, release, gui] + if: ${{ !cancelled() && needs.guard.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 30 environment: release-signing @@ -882,13 +970,13 @@ jobs: done ls -la + # `gh`, for the reasons given at the CLI job's upload. - name: Upload Release Asset - uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 - with: - files: ${{ runner.temp }}/win/* - tag_name: ${{ github.event.inputs.tag || github.ref_name }} + shell: bash env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: gh release upload "$TAG" "${RUNNER_TEMP}"/win/* --clobber # ─── latest.json for the in-app updater ─────────────────────────────── # Runs once every update file is final and signed: the macOS tarballs and @@ -900,15 +988,25 @@ jobs: # is visible before users meet it -- and once the app ships with a key, # it accepts nothing else, so a bad release there would be one every # install fails to update to. + # + # The release is still a draft here, so `gh` downloads through the API + # with this job's token. The public download URLs only work once it is + # published. + # + # The `if` replaces the default rather than restating it. GitHub applies a + # failure to every job further down a chain of `needs` unless that job + # has a condition of its own ("Defining prerequisite jobs" in its docs), so + # a failed CLI leg, which sign-windows is allowed to outlive, would skip + # this job as well. latest.json depends on the GUI legs and sign-windows, + # and on nothing else. updater-manifest: name: Publish latest.json needs: [gui, sign-windows] + if: ${{ !cancelled() && needs.gui.result == 'success' && needs.sign-windows.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: write - env: - TAG: ${{ github.event.inputs.tag || github.ref_name }} steps: # Default branch for the script, as elsewhere. The public key is read # from the tag below, because that is the one the shipped app holds. diff --git a/scripts/release/check-release-assets.mjs b/scripts/release/check-release-assets.mjs new file mode 100644 index 00000000..7cbbe5aa --- /dev/null +++ b/scripts/release/check-release-assets.mjs @@ -0,0 +1,181 @@ +/* Check that a draft release carries everything a release needs, before + * publish-release.yml makes it public. + * + * node scripts/release/check-release-assets.mjs vX.Y.Z + * + * publish-release.yml fetches the three inputs with `gh` and this decides: + * + * the release's assets from the GitHub API, one + * {name, size, digest} object per line + * the release's `.sha256` files + * the release's own copy of the updater manifest + * + * Why it exists: a release used to go public before its files did. v0.3.4 + * had no Windows installer and no latest.json for 1 h 38 min after it was + * published, and install.ps1, the in-app updater and every package manager + * that had already published pointed at nothing. Now the release is built + * as a draft and stays one until this passes, so "public" means "complete". + * + * What it checks, for each of the 18 files release.yml builds: the file + * and its .sha256, .sig and .pem are all there and none is empty, and the + * .sha256 names the digest GitHub computed for the uploaded file. A re-run + * that replaced a file but failed to replace its sidecar would otherwise + * publish a checksum that every installer then rejects. And latest.json is + * there, announces this version, and points every platform at this tag. + * + * What it does not check is whether the signatures verify. sign-windows + * checks every Authenticode signature with osslsigncode, and the + * updater-manifest job checks every updater signature against the app's + * public key, before either uploads anything. The Sigstore .sig and .pem + * files are checked for presence only. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +import { UPDATE_ASSETS } from './updater-manifest.mjs'; + +/** Every file release.yml builds. Each ships with the three SIDECARS. */ +export const ARTIFACTS = [ + // The `release` matrix: the CLI. + 'netscli-linux-x86_64', + 'netscli-linux-x86_64-pcap', + 'netscli-linux-x86_64-musl', + 'netscli-linux-aarch64', + 'netscli-linux-aarch64-pcap', + 'netscli-windows-x86_64.exe', + 'netscli-windows-x86_64-pcap.exe', + 'netscli-macos-x86_64', + 'netscli-macos-x86_64-pcap', + 'netscli-macos-aarch64', + 'netscli-macos-aarch64-pcap', + // The `gui` matrix: the desktop app. The .app.tar.gz files are what the + // macOS updater installs from. + 'netscli-gui-linux-x86_64.deb', + 'netscli-gui-linux-x86_64.AppImage', + 'netscli-gui-macos-aarch64.dmg', + 'netscli-gui-macos-x86_64.dmg', + 'netscli-gui-macos-aarch64.app.tar.gz', + 'netscli-gui-macos-x86_64.app.tar.gz', + 'netscli-gui-windows-x86_64.msi', +]; + +export const SIDECARS = ['.sha256', '.sig', '.pem']; + +const HEX64 = /^[0-9a-f]{64}$/; + +/** The problems with a draft, as sentences. Empty means it can go public. */ +export function checkReleaseAssets({ tag, assets, sidecars, latest }) { + const problems = []; + const byName = new Map(assets.map((asset) => [asset.name, asset])); + const version = tag.replace(/^v/, ''); + + for (const artifact of ARTIFACTS) { + for (const name of [artifact, ...SIDECARS.map((suffix) => `${artifact}${suffix}`)]) { + const asset = byName.get(name); + if (!asset) problems.push(`${name} is missing`); + else if (!(asset.size > 0)) problems.push(`${name} is empty`); + } + + const asset = byName.get(artifact); + if (!asset || !byName.has(`${artifact}.sha256`)) continue; + const sidecar = sidecars[`${artifact}.sha256`]; + if (sidecar === undefined) { + problems.push(`${artifact}.sha256 could not be read`); + continue; + } + const claimed = (sidecar.trim().split(/\s+/)[0] ?? '').toLowerCase(); + const actual = (/^sha256:(.*)$/.exec(asset.digest ?? '')?.[1] ?? '').toLowerCase(); + if (!HEX64.test(claimed)) { + problems.push(`${artifact}.sha256 holds no SHA-256 digest`); + } else if (!HEX64.test(actual)) { + problems.push(`GitHub reports no SHA-256 digest for ${artifact}`); + } else if (claimed !== actual) { + problems.push(`${artifact}.sha256 says ${claimed}, but the uploaded file is ${actual}`); + } + } + + if (!byName.has('latest.json')) { + problems.push('latest.json is missing'); + } else if (!latest) { + problems.push('latest.json could not be read'); + } else { + if (latest.version !== version) { + problems.push(`latest.json announces ${latest.version}, not ${version}`); + } + for (const [key, asset] of Object.entries(UPDATE_ASSETS)) { + const entry = latest.platforms?.[key]; + if (!entry) { + problems.push(`latest.json has no ${key} entry`); + continue; + } + if (typeof entry.url !== 'string' || !entry.url.endsWith(`/releases/download/${tag}/${asset}`)) { + problems.push(`latest.json sends ${key} to ${entry.url}, not to ${asset} on ${tag}`); + } + if (typeof entry.signature !== 'string' || entry.signature.trim() === '') { + problems.push(`latest.json has no signature for ${key}`); + } + } + } + + return problems; +} + +/** Assets on the release that nothing here expects. Reported, not failed: + * an extra file breaks nothing, but it usually means a name changed. */ +export function unexpectedAssets(assets) { + const expected = new Set([ + 'latest.json', + ...ARTIFACTS.flatMap((artifact) => [artifact, ...SIDECARS.map((suffix) => `${artifact}${suffix}`)]), + ]); + return assets.map((asset) => asset.name).filter((name) => !expected.has(name)); +} + +function readInputs(tag, assetsPath, sidecarDir, latestPath) { + const assets = readFileSync(assetsPath, 'utf8') + .split('\n') + .filter((line) => line.trim() !== '') + .map((line) => JSON.parse(line)); + const sidecars = {}; + if (existsSync(sidecarDir)) { + for (const name of readdirSync(sidecarDir)) { + if (name.endsWith('.sha256')) sidecars[name] = readFileSync(join(sidecarDir, name), 'utf8'); + } + } + let latest = null; + if (existsSync(latestPath)) { + try { + latest = JSON.parse(readFileSync(latestPath, 'utf8')); + } catch { + latest = null; + } + } + return { tag, assets, sidecars, latest }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const [tag, assetsPath, sidecarDir, latestPath] = process.argv.slice(2); + if (!tag || !assetsPath || !sidecarDir || !latestPath) { + console.error('usage: check-release-assets.mjs vX.Y.Z '); + process.exit(2); + } + if (!/^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(tag)) { + console.error(`not a release tag: ${tag}`); + process.exit(2); + } + const inputs = readInputs(tag, assetsPath, sidecarDir, latestPath); + for (const name of unexpectedAssets(inputs.assets)) { + console.log(`::warning::${tag} carries ${name}, which release.yml does not build. Check whether an asset was renamed.`); + } + const problems = checkReleaseAssets(inputs); + if (problems.length > 0) { + for (const problem of problems) console.log(`::error::${problem}`); + console.error(`${tag} is not ready to publish: ${problems.length} problem(s) above.`); + process.exit(1); + } + console.log( + `${tag}: all ${ARTIFACTS.length} files, their checksums and signatures, and latest.json are on the draft.`, + ); +} diff --git a/scripts/release/check-release-assets.test.mjs b/scripts/release/check-release-assets.test.mjs new file mode 100644 index 00000000..d709d431 --- /dev/null +++ b/scripts/release/check-release-assets.test.mjs @@ -0,0 +1,129 @@ +/* Tests for check-release-assets.mjs, the last check before a release goes + * public. + * + * It only ever runs during a release, so a mistake in it would surface + * then: either it blocks a good release, which is loud, or it passes an + * incomplete one, which is the failure it exists to prevent and is silent. + * These pin the second direction. Each case starts from a complete draft + * and takes away one thing a real release has lost. + * + * node --test scripts/release/check-release-assets.test.mjs + */ + +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { test } from 'node:test'; + +import { ARTIFACTS, SIDECARS, checkReleaseAssets, unexpectedAssets } from './check-release-assets.mjs'; +import { UPDATE_ASSETS } from './updater-manifest.mjs'; + +const TAG = 'v0.3.5'; + +const digestOf = (text) => createHash('sha256').update(text).digest('hex'); + +/** A draft with every file, each sidecar agreeing with its file. */ +function completeDraft() { + const assets = []; + const sidecars = {}; + for (const artifact of ARTIFACTS) { + const digest = digestOf(artifact); + assets.push({ name: artifact, size: 4096, digest: `sha256:${digest}` }); + for (const suffix of SIDECARS) { + assets.push({ name: `${artifact}${suffix}`, size: 128, digest: `sha256:${digestOf(suffix)}` }); + } + sidecars[`${artifact}.sha256`] = `${digest} ${artifact}\n`; + } + assets.push({ name: 'latest.json', size: 2048, digest: `sha256:${digestOf('latest')}` }); + const platforms = {}; + for (const [key, asset] of Object.entries(UPDATE_ASSETS)) { + platforms[key] = { + signature: Buffer.from(`untrusted comment: test\nsig\ntrusted comment: version:0.3.5\nglobal\n`).toString('base64'), + url: `https://github.com/fstubner/netscli/releases/download/${TAG}/${asset}`, + }; + } + const latest = { version: '0.3.5', notes: 'test', pub_date: '2026-10-07T00:00:00.000Z', platforms }; + return { tag: TAG, assets, sidecars, latest }; +} + +const without = (draft, name) => ({ ...draft, assets: draft.assets.filter((asset) => asset.name !== name) }); + +test('a complete draft passes', () => { + assert.deepEqual(checkReleaseAssets(completeDraft()), []); +}); + +test('the release builds 18 files', () => { + // A platform added to release.yml without being added here would never be + // checked, and one dropped from release.yml would fail every release. + assert.equal(ARTIFACTS.length, 18); + assert.equal(new Set(ARTIFACTS).size, ARTIFACTS.length); +}); + +test('a missing Windows installer fails it', () => { + const problems = checkReleaseAssets(without(completeDraft(), 'netscli-gui-windows-x86_64.msi')); + assert.deepEqual(problems, ['netscli-gui-windows-x86_64.msi is missing']); +}); + +test('a missing signed Windows CLI fails it', () => { + const problems = checkReleaseAssets(without(completeDraft(), 'netscli-windows-x86_64.exe')); + assert.deepEqual(problems, ['netscli-windows-x86_64.exe is missing']); +}); + +test('a missing sidecar fails it', () => { + const problems = checkReleaseAssets(without(completeDraft(), 'netscli-macos-aarch64.pem')); + assert.deepEqual(problems, ['netscli-macos-aarch64.pem is missing']); +}); + +test('an empty file fails it', () => { + const draft = completeDraft(); + draft.assets.find((asset) => asset.name === 'netscli-linux-x86_64-musl').size = 0; + assert.deepEqual(checkReleaseAssets(draft), ['netscli-linux-x86_64-musl is empty']); +}); + +test('a checksum that disagrees with the uploaded file fails it', () => { + // What a re-run leaves behind when it replaces a file and then fails to + // replace that file's sidecar. + const draft = completeDraft(); + draft.sidecars['netscli-gui-linux-x86_64.AppImage.sha256'] = `${digestOf('older build')} x\n`; + const problems = checkReleaseAssets(draft); + assert.equal(problems.length, 1); + assert.match(problems[0], /^netscli-gui-linux-x86_64\.AppImage\.sha256 says [0-9a-f]{64}, but the uploaded file is/); +}); + +test('a sidecar that holds no digest fails it', () => { + const draft = completeDraft(); + draft.sidecars['netscli-linux-aarch64.sha256'] = '\n'; + assert.deepEqual(checkReleaseAssets(draft), ['netscli-linux-aarch64.sha256 holds no SHA-256 digest']); +}); + +test('a missing latest.json fails it', () => { + const draft = { ...without(completeDraft(), 'latest.json'), latest: null }; + assert.deepEqual(checkReleaseAssets(draft), ['latest.json is missing']); +}); + +test('latest.json for another version fails it', () => { + const draft = completeDraft(); + draft.latest.version = '0.3.4'; + assert.deepEqual(checkReleaseAssets(draft), ['latest.json announces 0.3.4, not 0.3.5']); +}); + +test('latest.json pointing at another tag fails it', () => { + const draft = completeDraft(); + draft.latest.platforms['windows-x86_64-msi'].url = + 'https://github.com/fstubner/netscli/releases/download/v0.3.4/netscli-gui-windows-x86_64.msi'; + const problems = checkReleaseAssets(draft); + assert.equal(problems.length, 1); + assert.match(problems[0], /^latest\.json sends windows-x86_64-msi to .*v0\.3\.4/); +}); + +test('latest.json missing a platform fails it', () => { + const draft = completeDraft(); + delete draft.latest.platforms['darwin-x86_64-app']; + assert.deepEqual(checkReleaseAssets(draft), ['latest.json has no darwin-x86_64-app entry']); +}); + +test('extra assets are reported, not failed', () => { + const draft = completeDraft(); + draft.assets.push({ name: 'NetsCLI_0.3.5_x64_en-US.msi', size: 10, digest: `sha256:${digestOf('x')}` }); + assert.deepEqual(checkReleaseAssets(draft), []); + assert.deepEqual(unexpectedAssets(draft.assets), ['NetsCLI_0.3.5_x64_en-US.msi']); +}); diff --git a/scripts/release/lib.sh b/scripts/release/lib.sh index 67b666fc..f8f6fa7d 100644 --- a/scripts/release/lib.sh +++ b/scripts/release/lib.sh @@ -26,10 +26,11 @@ validate_tag() { # --- Asset availability -------------------------------------------------- # -# release.yml and publish.yml both fire on `release: published`, so the -# publish jobs routinely start before release.yml's matrix has finished -# uploading. Poll for up to 15 minutes, which covers the slowest matrix -# entry (Tauri bundle on Windows). +# publish-release.yml only starts publish.yml once every asset is on the +# release, so on that path the first probe succeeds. A release promoted by +# hand may start the publish jobs before all its assets are there, so poll +# for up to 15 minutes, which covers the slowest matrix entry (Tauri bundle +# on Windows) if a build is still uploading. # # Progress goes to stderr, deliberately. `verified_sha` calls this, and every # caller of `verified_sha` uses a command substitution — so anything written From fbf82a83495857b650d65792fa7194d68adb09f4 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:00:47 +0100 Subject: [PATCH 07/23] updater: bind every update signature to its version, and require it The Windows MSI and Linux AppImage signatures in 0.3.4's latest.json carry no signed version (decoded from the published file: macOS entries end in version:0.3.4, the other two have none). Both files are re-signed after the build, and the re-sign did not pass a version. With requireSignedVersion off, the plugin accepts such a signature, so anyone able to edit a release's latest.json could pair a higher version number with an older, validly signed installer. - release.yml re-signs the AppImage and the MSI with `tauri signer sign --app-version`. macOS keeps the bundler's signature, which already carries the version. - sign-windows installs the Tauri CLI in a step of its own, with no secret in it and --ignore-scripts, at the version package-lock.json locks. It used npx inside the signing step, beside the key, at a hand-typed 2.11.5 that had drifted from the lockfile's 2.12.1. - updater-manifest.mjs refuses a signature not bound to the release's version, and check-release-assets.mjs checks the uploaded latest.json the same way, so a missing --app-version fails the draft, not users. - tauri.conf.json sets plugins.updater.requireSignedVersion. Supported by the locked tauri-plugin-updater 2.13.1 (config.rs) and 2.12.0. Checked with the locked CLI 2.12.1 and a throwaway key: a signature made with --app-version 0.3.5 carries version:0.3.5 and builds latest.json, one made without it is refused. A copy of updater-manifest.mjs without the new check fails 2 of the 5 new tests. Audit desktop-app F7, ci-release L-6 (Tauri CLI pin). --- .github/workflows/release.yml | 36 +++++++-- apps/netscli-gui/src-tauri/tauri.conf.json | 1 + scripts/release/check-release-assets.mjs | 12 ++- scripts/release/check-release-assets.test.mjs | 21 ++++++ scripts/release/updater-manifest.mjs | 48 +++++++++++- scripts/release/updater-manifest.test.mjs | 74 +++++++++++++++++++ 6 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 scripts/release/updater-manifest.test.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 209eee0b..61b21bbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -662,12 +662,19 @@ jobs: # macOS updates install from a .app.tar.gz, a separate file from the # .dmg people download; it joins the release assets here so the digest # and sigstore steps below cover it like everything else. Its bundler - # signature is final, since nothing touches the tarball afterwards. + # signature is final, since nothing touches the tarball afterwards, + # and the bundler binds it to the app's version. # # Linux updates install the AppImage itself, which the host-library # fix above has already rewritten, so it is signed again now, on the # bytes that ship. The Windows MSI is handled in sign-windows. # + # `--app-version` binds the signature to this version. The app sets + # requireSignedVersion and refuses an update whose signature names no + # version, which 0.3.4's AppImage signature did not, because this + # re-sign did not pass one. updater-manifest.mjs now refuses such a + # signature before latest.json is built. + # # The signatures go to a workflow artifact, never to the release: the # sigstore sidecars below are also named `.sig`, and one would # overwrite the other. @@ -696,7 +703,7 @@ jobs: else image="${OUT_DIR}/${ASSET_PREFIX}.AppImage" [ -f "${image}" ] || { echo "::error::${image} missing"; exit 1; } - (cd apps/netscli-gui && npm run tauri -- signer sign "${image}") + (cd apps/netscli-gui && npm run tauri -- signer sign --app-version "${TAG#v}" "${image}") mv "${image}.sig" "${SIG_DIR}/${ASSET_PREFIX}.AppImage.sig" fi ls -la "${SIG_DIR}" @@ -897,6 +904,23 @@ jobs: CERTUM_OTP: ${{ secrets.CERTUM_OTP }} run: bash scripts/release/sign-windows.sh "${RUNNER_TEMP}/win" + # The Tauri CLI, for `signer sign` below. Installed in a step of its own, + # with no secret in its environment and no package's install scripts + # run, because the next step hands it the updater key. This used to be + # `npx --yes` inside that step, so the download ran beside the key. + # + # The version is the one apps/netscli-gui/package-lock.json locks on + # the default branch checked out above, so it follows Dependabot's + # bumps. It used to be typed here by hand "to match package.json", and + # had drifted to 2.11.5 while the lockfile moved to 2.12.1. + - name: Install the Tauri CLI + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./apps/netscli-gui/package-lock.json').packages['node_modules/@tauri-apps/cli'].version")" + npm install --prefix "${RUNNER_TEMP}/tauri-cli" --no-save --no-audit --no-fund --ignore-scripts "@tauri-apps/cli@${version}" + "${RUNNER_TEMP}/tauri-cli/node_modules/.bin/tauri" --version + # The in-app updater's signature, over the Authenticode-signed MSI. # # It has to come after Authenticode: that rewrites the file, so the @@ -904,9 +928,9 @@ jobs: # And it goes to a workflow artifact, not the release, because the # sigstore step below writes `.sig` too. # - # The CLI version is pinned to match apps/netscli-gui/package.json; - # this job checks out tooling only and does not install the app's - # dependencies. + # `--app-version` binds the signature to this version, which the app + # requires (requireSignedVersion). 0.3.4's MSI signature named no + # version, because this step did not pass one. - name: Sign the MSI for the in-app updater shell: bash env: @@ -916,7 +940,7 @@ jobs: set -euo pipefail msi="${RUNNER_TEMP}/win/netscli-gui-windows-x86_64.msi" [ -f "${msi}" ] || { echo "::error::${msi} missing"; exit 1; } - npx --yes @tauri-apps/cli@2.11.5 signer sign "${msi}" + "${RUNNER_TEMP}/tauri-cli/node_modules/.bin/tauri" signer sign --app-version "${TAG#v}" "${msi}" mkdir -p "${RUNNER_TEMP}/updater-sigs" mv "${msi}.sig" "${RUNNER_TEMP}/updater-sigs/" diff --git a/apps/netscli-gui/src-tauri/tauri.conf.json b/apps/netscli-gui/src-tauri/tauri.conf.json index 9512d0ae..5c81f47f 100644 --- a/apps/netscli-gui/src-tauri/tauri.conf.json +++ b/apps/netscli-gui/src-tauri/tauri.conf.json @@ -37,6 +37,7 @@ "endpoints": [ "https://github.com/fstubner/netscli/releases/latest/download/latest.json" ], + "requireSignedVersion": true, "windows": { "installMode": "passive" } diff --git a/scripts/release/check-release-assets.mjs b/scripts/release/check-release-assets.mjs index 7cbbe5aa..eef4e7ef 100644 --- a/scripts/release/check-release-assets.mjs +++ b/scripts/release/check-release-assets.mjs @@ -21,7 +21,8 @@ * .sha256 names the digest GitHub computed for the uploaded file. A re-run * that replaced a file but failed to replace its sidecar would otherwise * publish a checksum that every installer then rejects. And latest.json is - * there, announces this version, and points every platform at this tag. + * there, announces this version, points every platform at this tag, and + * carries signatures bound to this version, as the app requires. * * What it does not check is whether the signatures verify. sign-windows * checks every Authenticode signature with osslsigncode, and the @@ -35,7 +36,7 @@ import { join } from 'node:path'; import process from 'node:process'; import { pathToFileURL } from 'node:url'; -import { UPDATE_ASSETS } from './updater-manifest.mjs'; +import { UPDATE_ASSETS, signedVersion } from './updater-manifest.mjs'; /** Every file release.yml builds. Each ships with the three SIDECARS. */ export const ARTIFACTS = [ @@ -116,6 +117,13 @@ export function checkReleaseAssets({ tag, assets, sidecars, latest }) { } if (typeof entry.signature !== 'string' || entry.signature.trim() === '') { problems.push(`latest.json has no signature for ${key}`); + continue; + } + // The app sets requireSignedVersion, so a signature bound to no + // version, or to another one, is an update it refuses. + const signed = signedVersion(entry.signature); + if (signed?.replace(/^v/, '') !== version) { + problems.push(`latest.json's ${key} signature is bound to ${signed ?? 'no version'}, not ${version}`); } } } diff --git a/scripts/release/check-release-assets.test.mjs b/scripts/release/check-release-assets.test.mjs index d709d431..89b731de 100644 --- a/scripts/release/check-release-assets.test.mjs +++ b/scripts/release/check-release-assets.test.mjs @@ -121,6 +121,27 @@ test('latest.json missing a platform fails it', () => { assert.deepEqual(checkReleaseAssets(draft), ['latest.json has no darwin-x86_64-app entry']); }); +test('an updater signature bound to no version fails it', () => { + // 0.3.4's MSI and AppImage entries looked exactly like this. + const draft = completeDraft(); + draft.latest.platforms['windows-x86_64-msi'].signature = Buffer.from( + 'untrusted comment: test\nsig\ntrusted comment: timestamp:1791080847\tfile:netscli-gui-windows-x86_64.msi\nglobal\n', + ).toString('base64'); + assert.deepEqual(checkReleaseAssets(draft), [ + "latest.json's windows-x86_64-msi signature is bound to no version, not 0.3.5", + ]); +}); + +test('an updater signature bound to another version fails it', () => { + const draft = completeDraft(); + draft.latest.platforms['linux-x86_64-appimage'].signature = Buffer.from( + 'untrusted comment: test\nsig\ntrusted comment: timestamp:1\tfile:x.AppImage\tversion:0.3.4\nglobal\n', + ).toString('base64'); + assert.deepEqual(checkReleaseAssets(draft), [ + "latest.json's linux-x86_64-appimage signature is bound to 0.3.4, not 0.3.5", + ]); +}); + test('extra assets are reported, not failed', () => { const draft = completeDraft(); draft.assets.push({ name: 'NetsCLI_0.3.5_x64_en-US.msi', size: 10, digest: `sha256:${digestOf('x')}` }); diff --git a/scripts/release/updater-manifest.mjs b/scripts/release/updater-manifest.mjs index 83e0e6cb..67a1cf0f 100644 --- a/scripts/release/updater-manifest.mjs +++ b/scripts/release/updater-manifest.mjs @@ -14,6 +14,16 @@ * platform, not just its own. A missing signature therefore fails the build * rather than producing a latest.json with a gap in it. * + * Every signature must also be bound to this version: its trusted comment, + * which minisign's global signature covers, must carry `version:X.Y.Z`. The + * app sets `requireSignedVersion` (from 0.3.5), so it refuses an update + * whose signature names no version or another one. That is what stops an + * edited latest.json from pairing a new version number with an older, + * validly signed installer. 0.3.4's MSI and AppImage signatures carried no + * version, because those two are re-signed after the build and the re-sign + * did not pass one. Catching that here fails the release on the draft + * instead of shipping an update every 0.3.5 install would refuse. + * * Platform keys are `{os}-{arch}-{installer}`, never the bare `{os}-{arch}`. * The plugin tries the installer-specific key first and falls back to the * bare one, so a bare `linux-x86_64` would be offered to a .deb install and @@ -58,6 +68,26 @@ export function readSignature(path) { return raw; } +/* The version a signature was made for, or null if it names none. + * + * The Tauri CLI writes the trusted comment as tab-separated fields, + * `timestamp:...\tfile:...` and then `\tversion:X.Y.Z` when it is given a + * version: always by `tauri build` and `tauri bundle`, and by `tauri signer + * sign` only with `--app-version` (crates/tauri-cli/src/helpers/ + * updater_signature.rs at tauri-cli-v2.12.1). The plugin reads it the same + * way (tauri-plugin-updater 2.13.1, `signed_version`). */ +export function signedVersion(signature) { + const decoded = Buffer.from(signature, 'base64').toString('utf8'); + const comment = decoded.split('\n').find((line) => line.startsWith('trusted comment:')); + if (!comment) return null; + const field = comment + .slice('trusted comment:'.length) + .trim() + .split('\t') + .find((part) => part.startsWith('version:')); + return field ? field.slice('version:'.length).trim() : null; +} + /* The dialog renders notes as plain text, so the Markdown backticks the * site's summaries use for commands would show up literally. */ function notesFor(tag) { @@ -71,7 +101,9 @@ export function buildManifest(tag, signatureDir, now = new Date()) { if (!/^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/.test(tag)) { throw new Error(`not a release tag: ${tag}`); } + const version = tag.slice(1); const missing = []; + const unbound = []; const platforms = {}; for (const [key, asset] of Object.entries(UPDATE_ASSETS)) { const sigPath = join(signatureDir, `${asset}.sig`); @@ -79,16 +111,28 @@ export function buildManifest(tag, signatureDir, now = new Date()) { missing.push(`${asset}.sig`); continue; } + const signature = readSignature(sigPath); + // The plugin compares these as semver and ignores a leading `v`. + const signed = signedVersion(signature); + if (signed?.replace(/^v/, '') !== version) { + unbound.push(`${asset}.sig (${signed ? `signed for ${signed}` : 'no version'})`); + } platforms[key] = { - signature: readSignature(sigPath), + signature, url: `${REPO}/releases/download/${tag}/${asset}`, }; } if (missing.length > 0) { throw new Error(`missing updater signatures in ${signatureDir}: ${missing.join(', ')}`); } + if (unbound.length > 0) { + throw new Error( + `updater signatures not bound to ${version}: ${unbound.join(', ')}. ` + + `The app requires a signed version. Sign with \`tauri signer sign --app-version ${version}\`.`, + ); + } return { - version: tag.slice(1), + version, notes: notesFor(tag), pub_date: now.toISOString(), platforms, diff --git a/scripts/release/updater-manifest.test.mjs b/scripts/release/updater-manifest.test.mjs new file mode 100644 index 00000000..f215e72e --- /dev/null +++ b/scripts/release/updater-manifest.test.mjs @@ -0,0 +1,74 @@ +/* Tests for updater-manifest.mjs, which writes latest.json. + * + * The app sets `requireSignedVersion`, so every signature in latest.json has + * to carry the version it was made for. A release whose signatures do not + * is one every installed copy refuses to update to, and nothing but these + * checks would say so before users did. + * + * node --test scripts/release/updater-manifest.test.mjs + */ + +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { UPDATE_ASSETS, buildManifest, signedVersion } from './updater-manifest.mjs'; + +/** A Tauri signature file: base64 around a minisign signature. Only the two + * comment lines matter to the code under test. */ +const signature = (trustedComment) => + Buffer.from( + `untrusted comment: signature from tauri secret key\nRUQfake\ntrusted comment: ${trustedComment}\nfakeglobal\n`, + ).toString('base64'); + +/** A directory with one signature per update file, `comment(asset)` each. */ +function signatureDir(comment) { + const dir = mkdtempSync(join(tmpdir(), 'updater-sigs-')); + for (const asset of Object.values(UPDATE_ASSETS)) { + writeFileSync(join(dir, `${asset}.sig`), signature(comment(asset))); + } + return dir; +} + +test('reads the version from a signature that has one', () => { + assert.equal(signedVersion(signature('timestamp:1\tfile:app.msi\tversion:0.3.5')), '0.3.5'); +}); + +test('reads no version from a signature made without one', () => { + assert.equal(signedVersion(signature('timestamp:1791080847\tfile:netscli-gui-windows-x86_64.msi')), null); +}); + +test('builds latest.json when every signature is bound to the version', () => { + const dir = signatureDir((asset) => `timestamp:1\tfile:${asset}\tversion:0.3.5`); + try { + const manifest = buildManifest('v0.3.5', dir); + assert.equal(manifest.version, '0.3.5'); + assert.deepEqual(Object.keys(manifest.platforms).sort(), Object.keys(UPDATE_ASSETS).sort()); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('refuses a signature bound to no version', () => { + // How 0.3.4's MSI and AppImage were signed: re-signed after the build, + // without --app-version. + const dir = signatureDir((asset) => + asset.endsWith('.msi') ? `timestamp:1\tfile:${asset}` : `timestamp:1\tfile:${asset}\tversion:0.3.5`, + ); + try { + assert.throws(() => buildManifest('v0.3.5', dir), /not bound to 0\.3\.5: netscli-gui-windows-x86_64\.msi\.sig \(no version\)/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('refuses a signature bound to another version', () => { + const dir = signatureDir((asset) => `timestamp:1\tfile:${asset}\tversion:0.3.4`); + try { + assert.throws(() => buildManifest('v0.3.5', dir), /signed for 0\.3\.4/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From e7f50015c42ee6bfa593c120e9c75f3313f739c3 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:02:03 +0100 Subject: [PATCH 08/23] release: keep the signing secrets out of the compile and npm install steps The GUI job's single `tauri build` step held TAURI_SIGNING_PRIVATE_KEY and its password on every leg, and CERTUM_EMAIL/CERTUM_OTP on Windows, while it compiled the whole Rust dependency graph (build scripts and proc macros) and ran beforeBuildCommand (tsc and Vite). `npm ci`, with install scripts enabled, ran earlier in the same job. Both keys are long-lived, and the updater key cannot be replaced without every user reinstalling. - `tauri build` is now `tauri build --no-bundle` with no secrets, then `tauri bundle` with them. That is what `tauri build` does internally (tauri-cli 2.12.1 build.rs calls the same bundle function), with the same --config files on both steps. - `npm ci --ignore-scripts`. The lockfile's only install scripts are edgedriver (end-to-end suite) and fsevents (optional), which the build does not need. The bundle step still holds the keys, because the bundler signs the update files and runs signCommand. A step boundary is not a sandbox, the workflow comment says so. Audit M18 (ci-release M-2). --- .github/workflows/release.yml | 35 ++++++++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61b21bbd..b613e44b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -450,8 +450,14 @@ jobs: libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev \ librsvg2-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev + # `--ignore-scripts`: this job later hands the updater key and, on + # Windows, the Certum credentials to tools from node_modules, so no + # package gets to run code while it is being installed. The lockfile + # has two install scripts, edgedriver (the end-to-end suite's driver + # download) and fsevents (an optional macOS file watcher), and the + # build needs neither. - name: npm ci - run: npm ci + run: npm ci --ignore-scripts working-directory: apps/netscli-gui # The trailing `-- --locked` is forwarded by the Tauri CLI to cargo, so @@ -540,13 +546,36 @@ jobs: # Only the macOS signatures survive as-is. The AppImage is repacked # below and the MSI is Authenticode-signed in sign-windows, and both # change the bytes, so those two are signed again after that. - - name: tauri build + # + # Two steps, which is all `tauri build` is: compile, then bundle + # (crates/tauri-cli/src/build.rs at tauri-cli-v2.12.1 calls the same + # bundle function `tauri bundle` does). Split, so that the step which + # compiles every Rust dependency's build scripts and proc macros and + # runs `beforeBuildCommand` (tsc and Vite) has no secret in its + # environment. The keys are needed only by the bundler, which signs + # the update files and, through signCommand, the Windows executable. + # + # Both steps take the same --config files, so the app compiled here + # embeds the same configuration a single `tauri build` would. + # + # A step boundary is not a sandbox: code run by the first step could + # leave something behind for the second. What it removes is the + # simplest case, a dependency reading credentials out of its own + # environment while it builds. + - name: tauri build (compile only, no secrets) run: >- - npm run tauri -- build --target ${{ matrix.target }} + npm run tauri -- build --no-bundle --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json ${{ runner.os == 'Windows' && format('--config {0}', env.NETSCLI_SIGN_CONFIG) || '' }} -- --locked working-directory: apps/netscli-gui + + - name: tauri bundle (installers and their signatures) + run: >- + npm run tauri -- bundle --target ${{ matrix.target }} + --config src-tauri/tauri.release.conf.json + ${{ runner.os == 'Windows' && format('--config {0}', env.NETSCLI_SIGN_CONFIG) || '' }} + working-directory: apps/netscli-gui env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} From 86909416e86bf4c4d0b227ed9af4e5adf3ea1f2e Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:04:59 +0100 Subject: [PATCH 09/23] publish: crates.io token off argv, re-runnable crates job, signed MCP bundles crates.io (M21, L-4) - The token was on the `cargo publish` command line, in the same step that builds every packaged crate, so every build script ran with it in its environment and argv. Now `cargo publish --dry-run` packages and builds with no token, and `cargo publish --no-verify` uploads with the token in CARGO_REGISTRY_TOKEN and compiles nothing. - A re-run after a partial upload failed on the crate that was already up and never reached the rest. The job now asks crates.io which of the three already have the version and publishes only the others. MCP bundles (M23, M19) - The five .mcpb files were the only executable-carrying assets with no .sha256, .sig or .pem. A new mcpb-sign job hashes and cosign-signs them the way release.yml signs everything else (identity: publish.yml@refs/heads/main) and uploads all three files per bundle. - The bundles are built with `npx @anthropic-ai/mcpb`, whose dependencies are caret ranges resolved on every run. That job now has a read-only token and no OIDC. The new job, which holds both, runs no package code. Tooling and pins (L-5, L-6) - homebrew, scoop, aur and their GUI siblings check out the default branch, as npm, mcpb and mcp-registry already did, so a fix to their scripts reaches a re-run. crates-io keeps the tag: it builds the source. - npm for trusted publishing is pinned to 11.21.0 instead of ^11.5.1. The summary points at `-f only=` for re-running one registry, and the `only` comment lists the job ids. Audit M21, M23, ci-release L-4, L-5, L-6. --- .github/workflows/publish.yml | 209 ++++++++++++++++++++++++++++------ 1 file changed, 175 insertions(+), 34 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a35a755e..ad999411 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -31,8 +31,13 @@ on: # replays the original run, including the workflow file as it was # then, so a fix merged since is invisible to it. Only a fresh # dispatch picks up a changed workflow -- and a fresh dispatch without - # this input runs the whole fan-out, which means Winget opens a second - # PR against microsoft/winget-pkgs for a version it already has. + # this input runs the whole fan-out again. Most jobs would find their + # work done (crates.io and npm skip versions they already have, Komac + # finds its own winget PR, the tap and bucket scripts commit nothing), + # but every registry is contacted again. Name the one job instead. + # The ids are the job ids below: crates-io, npm, homebrew, scoop, + # winget, aur, homebrew-cask, scoop-gui, winget-gui, aur-gui, mcpb, + # mcp-registry. # # v0.3.3 hit this three times before the pattern was clear: npm # rejected a package name, the fix merged, and three successive @@ -55,16 +60,19 @@ on: # The jobs below push to registries the moment they succeed, and AUR has no # review step — cancelling halfway leaves some registries on the new version # and some on the old, with no record of which. Queueing costs a few idle -# minutes; the duplicate run is a no-op, since every publish script is -# idempotent for a tag it has already pushed. +# minutes, and the duplicate run finds most of its work already done (see +# the `only` input above). concurrency: group: publish-${{ github.event.inputs.tag || github.ref_name }} cancel-in-progress: false # These jobs hold five publishing secrets between them (crates.io, the tap, -# the bucket, winget, AUR) and every one of those is a PAT or an SSH key — +# the bucket, winget, AUR), plus NPM_TOKEN for bootstrapping a new npm +# package, and every one of those is a PAT, an SSH key or an npm token -- # none of them needs GITHUB_TOKEN to write to this repo. Read is what the -# checkouts need and nothing here wants more. +# checkouts need. The three jobs that need more say so themselves: npm and +# mcp-registry mint OIDC credentials, and mcpb-sign signs and uploads the +# MCP bundles. permissions: contents: read @@ -105,9 +113,9 @@ jobs: # transactional across registries either way -- that is a property of the # design, not something a timeout introduces. # ─── crates.io ────────────────────────────────────────────────────── - # Bottom-up publish (netscli-core → netscli-mcp → netscli). Sleep - # between each to let the index update; downstream crates' resolvers - # otherwise can't see the just-published version. + # netscli-core, netscli-mcp and netscli, from the tag. This is the one job + # that builds the source rather than downloading release assets, so it is + # also the one that checks out the tag rather than the default branch. crates-io: name: crates.io if: inputs.only == '' || inputs.only == 'crates-io' @@ -137,22 +145,72 @@ jobs: # ships. The only way out is burning the next patch number across all # three. # - # Publishing the set in one command removes that. Cargo resolves the + # Handling the set in one command removes that. Cargo resolves the # not-yet-published inter-crate dependencies from the workspace, - # packages every member, builds each packaged copy to verify it - # compiles standing alone, and only then uploads -- in dependency - # order, waiting on the index itself rather than on a guessed sleep. - # Anything wrong with packaging or compilation is caught while nothing - # has been uploaded. + # packages every member and builds each packaged copy to verify it + # compiles standing alone (the dry run below), and only then uploads + # -- in dependency order, waiting on the index itself rather than on a + # guessed sleep (the upload below). Anything wrong with packaging or + # compilation is caught while nothing has been uploaded. # # What this does NOT make atomic: an upload failing partway through # after an earlier one succeeded. crates.io has no transaction across # crates, so that window cannot be closed from here -- it is just far # narrower than "the CLI does not package". - - name: cargo publish (all three crates) - run: cargo publish -p netscli-core -p netscli-mcp -p netscli --token "$CARGO_TOKEN" + # + # What it can do is make the job re-runnable after that happens. + # cargo refuses to upload a version that already exists, so a re-run + # that names all three after netscli-core landed fails on netscli-core + # and never reaches the other two. This step asks crates.io which of + # the three already have the version, and the steps below publish the + # rest. A crate that is up there resolves from the index instead of + # the workspace, which is the same code. + - name: Find the crates not yet on crates.io + id: pending + env: + TAG: ${{ env.TAG }} + run: | + set -euo pipefail + . scripts/release/lib.sh + validate_tag "$TAG" + version="${TAG#v}" + args="" + for crate in netscli-core netscli-mcp netscli; do + # crates.io's API asks for a User-Agent naming the caller. + code=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H 'User-Agent: netscli-release (github.com/fstubner/netscli)' \ + "https://crates.io/api/v1/crates/${crate}/${version}") + case "$code" in + 200) echo "${crate} ${version} is already on crates.io; skipping it." ;; + 404) args="${args} -p ${crate}" ;; + *) echo "::error::crates.io answered HTTP ${code} for ${crate} ${version}"; exit 1 ;; + esac + done + echo "args=${args# }" >> "$GITHUB_OUTPUT" + [ -n "$args" ] || echo "All three crates are already published at ${version}. Nothing to do." + + # Packages each crate and builds the packaged copy, so it is the step + # that runs every dependency's build scripts. It has no token: a dry + # run never authenticates (publish-preflight.yml says how that was + # checked). + - name: Package and build the crates (no token) + if: steps.pending.outputs.args != '' env: - CARGO_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + ARGS: ${{ steps.pending.outputs.args }} + # $ARGS is a list of `-p ` words, split on purpose. + run: cargo publish --dry-run $ARGS + + # The upload, with the token in cargo's own environment variable + # rather than on the command line, where it was visible in the + # process table to everything the build started. `--no-verify` + # because the step above verified exactly these crates, so this step + # compiles nothing and runs no build script while it holds the token. + - name: Upload the crates + if: steps.pending.outputs.args != '' + env: + ARGS: ${{ steps.pending.outputs.args }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: cargo publish --no-verify $ARGS # ─── npm ──────────────────────────────────────────────────────────── # One launcher package plus five prebuilt-binary packages. This is what @@ -204,8 +262,13 @@ jobs: # it would fall back to looking for a token and fail with an auth # error rather than anything naming the real cause. 11.5.1 is the # first version that supports it. + # + # An exact version, not a range. This job can mint npm's OIDC + # credential, and `^11.5.1` installed whatever npm 11 was newest at + # run time. Dependabot does not see this line, so bump it by hand: + # `npm view npm@11 version` lists the candidates. - name: Upgrade npm for trusted publishing - run: npm install -g npm@^11.5.1 + run: npm install -g npm@11.21.0 # Normally unset: all six packages have a trusted publisher, and the # script uses OIDC whenever NPM_TOKEN is empty. @@ -227,10 +290,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Release tooling, so the default branch. See the npm job above. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ env.TAG }} + ref: ${{ github.event.repository.default_branch }} - name: Update fstubner/homebrew-tap env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} @@ -243,10 +307,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Release tooling, so the default branch. See the npm job above. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ env.TAG }} + ref: ${{ github.event.repository.default_branch }} - name: Update fstubner/scoop-bucket env: GH_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} @@ -384,10 +449,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Release tooling, so the default branch. See the npm job above. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ env.TAG }} + ref: ${{ github.event.repository.default_branch }} # The tag reaches a `sed` replacement in the render step below, and # `workflow_dispatch` lets anyone with repo write access supply an @@ -496,10 +562,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Release tooling, so the default branch. See the npm job above. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ env.TAG }} + ref: ${{ github.event.repository.default_branch }} - name: Update fstubner/homebrew-tap (Cask) env: GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} @@ -515,10 +582,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Release tooling, so the default branch. See the npm job above. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ env.TAG }} + ref: ${{ github.event.repository.default_branch }} - name: Update fstubner/scoop-bucket (GUI) env: GH_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} @@ -639,10 +707,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: + # Release tooling, so the default branch. See the npm job above. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - ref: ${{ env.TAG }} + ref: ${{ github.event.repository.default_branch }} # Same reasoning as the CLI AUR job: the tag reaches an inline `sed` # below, and workflow_dispatch makes it caller-controlled. @@ -735,16 +804,17 @@ jobs: # published assets and verifies their checksums, exactly as the Homebrew # and Scoop jobs do -- release.yml's matrix legs upload their own outputs # and never see each other's. + # + # Two jobs. This one builds the bundles with `npx @anthropic-ai/mcpb`, + # whose dependencies are caret ranges resolved afresh on every run, so it + # gets a read-only token and no OIDC. `mcpb-sign` below, which holds + # both, runs no package code: it hashes, signs and uploads what this one + # built. mcpb: name: MCP bundles if: inputs.only == '' || inputs.only == 'mcpb' runs-on: ubuntu-latest timeout-minutes: 30 - permissions: - # The only job here that writes. It uploads to the release it read - # from; the workflow-level default is read, so this is the exception - # rather than a loosening for everyone. - contents: write steps: # The default branch, for the same reason as the npm job above: this # checkout provides the bundle script and the manifest template, and @@ -761,6 +831,73 @@ jobs: - name: Build bundles run: bash scripts/release/build-mcpb.sh "$TAG" "${RUNNER_TEMP}/mcpb" + - name: Hand the bundles to the signing job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: mcpb-bundles + path: ${{ runner.temp }}/mcpb/*.mcpb + retention-days: 1 + if-no-files-found: error + + # The bundles were the only release assets that carry executables and had + # no .sha256, .sig or .pem, while the docs said every asset is signed. Now + # they get all three, made the same way release.yml makes them. Their + # Sigstore identity is this file rather than release.yml, because the + # bundles are built after the release is public: + # https://github.com/fstubner/netscli/.github/workflows/publish.yml@refs/heads/main + mcpb-sign: + name: MCP bundles (sign and attach) + needs: mcpb + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + # The one job here that writes to this repository. It uploads to the + # release it read from. + contents: write + # Sigstore keyless signing, as in release.yml. + id-token: write + steps: + - name: Collect the bundles + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: mcpb-bundles + path: ${{ runner.temp }}/mcpb + + - name: Generate SHA256 + shell: bash + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/mcpb" + for f in *.mcpb; do + sha256sum "$f" > "${f}.sha256" + done + cat ./*.sha256 + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + # The same pin as release.yml, for the reasons given there. + cosign-release: v3.0.6 + + - name: Sign with sigstore + shell: bash + env: + COSIGN_EXPERIMENTAL: "1" + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/mcpb" + for f in *.mcpb; do + # Both flags as in release.yml's CLI signing step, where the + # reason for each is written out. + cosign sign-blob --yes \ + --use-signing-config=false \ + --new-bundle-format=false \ + --output-signature "${f}.sig" \ + --output-certificate "${f}.pem" \ + "$f" + done + ls -la + # `--clobber` so a re-run after a partial upload replaces what is # there rather than failing on the first bundle that already exists. # Every other job here is re-runnable and this one should be too. @@ -768,7 +905,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} - run: gh release upload "$TAG" "${RUNNER_TEMP}"/mcpb/*.mcpb --clobber + run: gh release upload "$TAG" "${RUNNER_TEMP}"/mcpb/* --clobber # ─── MCP Registry ─────────────────────────────────────────────────── # The official directory MCP clients browse (registry.modelcontextprotocol.io). @@ -813,8 +950,8 @@ jobs: # The jobs above are deliberately independent — crates.io landing # while AUR fails is a normal, recoverable state, and each job re-runs on # its own. The cost of that independence was that "did this release - # actually reach every registry?" could only be answered by opening nine - # job pages, and a single failure among eight greens is easy to miss. + # actually reach every registry?" could only be answered by opening a + # dozen job pages, and a single failure among the greens is easy to miss. # # This job REPORTS, it does not gate, and that is on purpose: failing the # run on a partial publish would contradict the independence above and @@ -834,6 +971,7 @@ jobs: - npm - mcp-registry - mcpb + - mcpb-sign - homebrew - scoop - winget @@ -874,7 +1012,10 @@ jobs: { echo "" echo "**${TAG} did not reach every registry.** Re-run the failed jobs from the" - echo "Actions tab, or \`workflow_dispatch\` this workflow with tag \`${TAG}\`." + echo "Actions tab. If the fix was to this workflow or its scripts, dispatch it" + echo "again for that one job, which picks the fix up:" + echo "\`gh workflow run publish.yml -f tag=${TAG} -f only=\`." + echo "\`mcpb-sign\` runs with \`only=mcpb\`." } >> "$GITHUB_STEP_SUMMARY" else echo "All registries published for ${TAG}." From 8faea26c8a6b16fd7aa0cfe28e7041f8a81192cd Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:18:37 +0100 Subject: [PATCH 10/23] site: present only published releases as released netscli.com showed the unpublished 0.3.5 as current: the JSON-LD softwareVersion came from apps/netscli-gui/package.json, and the no-script changelog page dated 0.3.5 "Oct 6, 2026" from its CHANGELOG heading. Pages deploys from main, where a version is bumped, tagged and dated before its release is published, so nothing in the repository can say which versions are out. GitHub can. - version.ts reads NETSCLI_PUBLISHED_RELEASES, which pages.yml will set from the Releases API. The site's version is the newest published release. Set but empty (GitHub could not be asked) means no version, and Page.astro then leaves softwareVersion out of the JSON-LD rather than guess. Unset (local, CI and preview builds) falls back to package.json as before. - changelog.astro's build-time render gives every entry GitHub does not list "Not yet released" and no date, as the client already does once GitHub answers. With an empty list, no entry is dated. - check:changelog checks that rule when run with the variable set, and keeps the old check without it. - CHANGELOG.md's preamble said a date goes on "only once its release is published" and, three lines later, "with the tag". It now gives the one rule the process follows: the date goes on once the tag is pushed, is the intended publish day, and says nothing about publication. Built locally three ways. Unset: unchanged (softwareVersion 0.3.5, v0.3.5 dated), check:changelog passes. With the 13 tags GitHub lists today: softwareVersion 0.3.4, v0.3.5 "Not yet released" and undated, v0.3.4 and v0.3.3 dated, check passes. Empty: no softwareVersion, no dates, check passes. The check, run with today's list against the page as it is deployed now, fails on 0.3.5. astro check: 0 errors, 0 warnings; check:structured-data passes on all three builds. Audit M26 (site-docs-copy F6, repo-docs 8). --- CHANGELOG.md | 13 ++++--- site/scripts/changelog-dates.mjs | 55 +++++++++++++++++++++++++-- site/src/data/site-content/version.ts | 45 +++++++++++++++++++++- site/src/layouts/Page.astro | 4 +- site/src/pages/changelog.astro | 29 +++++++++++++- 5 files changed, 133 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b48f3d..ac9cd2f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,14 @@ they do not *inherit* it. Each crate sets its own, and the GUI carries further copies in `package.json` and `tauri.conf.json`. See `docs/PUBLISHING.md` for the full list of files a bump has to touch. -A version heading carries a date, and a link, only once its release is -published. Both are claims about the outside world, and the website reads -them. It printed "24 Aug 2026" for 0.3.1 for four days on the strength of a -date written here when the notes were drafted. An in-flight version keeps -its heading and collects entries. The date and the link go on with the tag. +A version heading gets its date and its link in a commit of their own, once +the version's tag is pushed. The date is the day the release is meant to go +out, and it moves if the release slips. Neither says the release is +published. 0.3.1 was dated here when its notes were drafted, before it was +even tagged, and the website showed it as released for four days. So the +website now asks GitHub which releases are published, and labels any other +version "Not yet released" whatever its heading says. An in-flight version +keeps a bare heading and collects entries. ## [0.3.5] - 2026-10-06 diff --git a/site/scripts/changelog-dates.mjs b/site/scripts/changelog-dates.mjs index 98dfb170..c62a3ed4 100644 --- a/site/scripts/changelog-dates.mjs +++ b/site/scripts/changelog-dates.mjs @@ -16,6 +16,16 @@ // truth. Run after `astro build`. // // node ./scripts/changelog-dates.mjs +// +// A third way, found later: a dated heading is not a published release. The +// date goes on once the tag is pushed, before the release is public, so the +// live page showed 0.3.5 with its date while the release was a draft. The +// build that deploys (pages.yml) is told which releases GitHub lists as +// published, in NETSCLI_PUBLISHED_RELEASES (see +// src/data/site-content/version.ts). Run with the same variable set, this +// checks that the page dates exactly those, and labels every other entry +// "Not yet released" without a date. Without it, it checks that the page +// relays CHANGELOG.md's dates as written. import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; @@ -55,12 +65,25 @@ const cards = html // that div cuts the head off before the date it is meant to find. const bodyAt = chunk.indexOf('class="release-body"'); const head = chunk.slice(0, bodyAt === -1 ? chunk.length : bodyAt); + const heading = (head.match(/]*>([\s\S]*?)<\/h2>/) || [, ''])[1]; return { - label: (head.match(/]*>([\s\S]*?)<\/h2>/) || [, ''])[1].replace(/<[^>]+>/g, '').trim(), + // The "Not yet released" label is a span inside the heading; the + // version is the rest of it. + label: heading + .replace(/]*>[\s\S]*?<\/span>/, '') + .replace(/<[^>]+>/g, '') + .trim(), + notYetReleased: /class="release-unreleased"/.test(heading), date: (head.match(/class="release-meta"[^>]*>([^<]*) 0 && !card.notYetReleased) { + failures.push(`${version}: GitHub lists no published release for it, and the page does not say "Not yet released".`); + } + continue; + } + if (card.notYetReleased) { + failures.push(`${version}: the page says "Not yet released" about a published release.`); + } if (date && !card.date) { failures.push( `${version}: CHANGELOG.md dates it ${date}, the page shows no date. ` + @@ -139,10 +178,18 @@ if (failures.length > 0) { console.error('Changelog dates on the built page disagree with CHANGELOG.md:\n'); for (const failure of failures) console.error(` - ${failure}`); console.error( - '\nA version heading carries a date only once its release is published;' + - '\nthe page must relay exactly that. See the note at the top of CHANGELOG.md.' + '\nA version heading is dated once its tag exists. The page dates a version only' + + '\nwhen it may: as CHANGELOG.md says in a build that was not told which releases' + + '\nare published, and only for published releases in one that was. See the note' + + '\nat the top of CHANGELOG.md.' ); process.exit(1); } -console.log(`${declared.length} changelog entr(ies) render the date CHANGELOG.md declares.`); +console.log( + published === null + ? `${declared.length} changelog entr(ies) render the date CHANGELOG.md declares.` + : published.size === 0 + ? `${declared.length} changelog entr(ies) checked. The build was not told any published release, and dates none.` + : `${declared.length} changelog entr(ies) checked against the ${published.size} published release(s) GitHub lists.` +); diff --git a/site/src/data/site-content/version.ts b/site/src/data/site-content/version.ts index c4c1b5b8..56ec23e9 100644 --- a/site/src/data/site-content/version.ts +++ b/site/src/data/site-content/version.ts @@ -1,9 +1,50 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -/** Read the shipped product version from the GUI package.json so the site - * never needs a manual version bump of its own. */ +const RELEASE_TAG = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/; +const STABLE_TAG = /^v(\d+)\.(\d+)\.(\d+)$/; + +/** The release tags GitHub lists as published, when the build was told. + * + * The site deploys from main, and on main a version is bumped, tagged and + * dated before its release is published, so nothing in the repository says + * which versions are out. pages.yml asks GitHub's Releases API before the + * build and passes the answer in NETSCLI_PUBLISHED_RELEASES, space + * separated. Three cases, kept apart on purpose: + * + * - unset: a local, CI or preview build, which was not told. Callers fall + * back to what the repository says. + * - set but empty: pages.yml could not ask GitHub. Callers claim nothing + * about releases. + * - a list: exactly the published releases. + */ +export function readPublishedReleases(): string[] | undefined { + const raw = process.env.NETSCLI_PUBLISHED_RELEASES; + if (raw === undefined) return undefined; + return raw.split(/\s+/).filter((tag) => RELEASE_TAG.test(tag)); +} + +/** The version the site presents as current: the newest published release, + * pre-releases aside, so netscli.com never names a version nobody can + * install yet. 0.3.5 showed as current, in the landing page's structured + * data, while its release was still a draft. + * + * An empty string when pages.yml could not ask GitHub, and the page then + * leaves the version out. Builds that were not told (unset) still read + * apps/netscli-gui/package.json, which is what a local preview wants. */ export function readProductVersion(): string { + const published = readPublishedReleases(); + if (published === undefined) return readPackageVersion(); + const stable = published + .map((tag) => STABLE_TAG.exec(tag)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => [Number(match[1]), Number(match[2]), Number(match[3])] as const) + .sort((a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]); + const newest = stable.at(-1); + return newest ? newest.join('.') : ''; +} + +function readPackageVersion(): string { const packagePath = [ join(process.cwd(), '..', 'apps', 'netscli-gui', 'package.json'), join(process.cwd(), 'apps', 'netscli-gui', 'package.json'), diff --git a/site/src/layouts/Page.astro b/site/src/layouts/Page.astro index c5eede45..65e2ff48 100644 --- a/site/src/layouts/Page.astro +++ b/site/src/layouts/Page.astro @@ -53,7 +53,9 @@ const softwareApp = { applicationCategory: 'DeveloperApplication', applicationSubCategory: 'NetworkingApplication', operatingSystem: 'Windows, macOS, Linux', - softwareVersion: version, + // The newest published release (site-content/version.ts). Left out, + // rather than guessed, when the build could not ask GitHub. + ...(version ? { softwareVersion: version } : {}), license: 'https://opensource.org/licenses/MIT', programmingLanguage: 'Rust', codeRepository: site.hero.sourceUrl, diff --git a/site/src/pages/changelog.astro b/site/src/pages/changelog.astro index baa8fc94..d4877007 100644 --- a/site/src/pages/changelog.astro +++ b/site/src/pages/changelog.astro @@ -10,6 +10,7 @@ import { changelogOgDescription, releaseSummaries, } from '../data/site-content/changelog'; +import { readPublishedReleases } from '../data/site-content/version'; const { hero, social } = site; const title = changelogCopy.heading; @@ -72,12 +73,38 @@ const parseLocalChangelog = (text: string) => { const fallbackReleases = parseLocalChangelog(changelogText); +/** + * What the HTML may claim about each entry, before any JavaScript runs. + * + * A dated CHANGELOG.md heading is not a published release: the date goes on + * once the tag is pushed, which is before the release goes public. So the + * no-script page used to show 0.3.5 with its date while the release was + * still a draft. pages.yml now tells the build which releases GitHub lists + * as published (site-content/version.ts), and an entry it does not list + * loses its date and is labelled "Not yet released", which is what the + * client already does once GitHub answers. If pages.yml could not ask, no + * entry keeps a date. A build that was not told (local, CI, preview) renders + * the dates as written. + * + * Only the build-time render changes. The client still gets the entries as + * written and makes its own call from GitHub's answer. + */ +const published = readPublishedReleases(); +const staticReleases = + published === undefined + ? fallbackReleases + : fallbackReleases.map((release) => { + if (published.includes(release.tag_name)) return release; + const undated = { ...release, published_at: '' }; + return published.length > 0 ? { ...undated, unreleased: true, confirmedUnreleased: true } : undated; + }); + // Rendered here, not painted in by script: the entries come from this // repository's own CHANGELOG.md, so they are known at build time, and a page // whose whole purpose is release notes should not be empty without // JavaScript. The client still upgrades this once GitHub confirms which // tags are actually released. -const staticRelease = renderReleaseListHtml(fallbackReleases, hero.sourceUrl.split('/').slice(-2).join('/'), releaseSummaries); +const staticRelease = renderReleaseListHtml(staticReleases, hero.sourceUrl.split('/').slice(-2).join('/'), releaseSummaries); --- Date: Wed, 7 Oct 2026 19:18:47 +0100 Subject: [PATCH 11/23] pages: per-job permissions, MSI staging away from npm, published version pages.yml (M22, M26, L-7) - Three jobs. `build` runs npm ci and astro build with a read-only token. `stage` runs no npm: it requires site/dist/install.sh and install.ps1, which the one-liners pipe into a shell, to equal this commit's scripts/install.* byte for byte, refuses a download/ directory it did not write, stages the Store MSIs and checks each one's Authenticode signature with osslsigncode (signer CN "Open Source Developer Felix Stubner", as printed in the v0.3.3 sign-windows log). `deploy` is the only job with pages: write and id-token: write. Installers from before v0.3.3 were never signed and are staged as published; v0.3.2 is among the three staged today. - Asks the Releases API which releases are published and passes the list to the build in NETSCLI_PUBLISHED_RELEASES, then runs check:changelog on the build that ships. - timeout-minutes on all three jobs. stage-store-downloads.sh (L-7) - Lists each release's assets instead of reading any failed download, with its error output discarded, as "no MSI". An API or network error used to skip the newest release while older ones staged and the job passed. Audit M22, M26, ci-release L-7. --- .github/workflows/pages.yml | 164 +++++++++++++++++++++-- scripts/release/stage-store-downloads.sh | 20 ++- 2 files changed, 170 insertions(+), 14 deletions(-) diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 07e8a81b..123ba674 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -7,21 +7,33 @@ name: Deploy GitHub Pages # mid review, because auto-deploying on every main merge had shipped that # redesign to netscli.com once before it was ready. The redesign shipped on # 2026-09-13, so the condition is gone and deploys are automatic again. -# `workflow_dispatch` stays alongside it, for redeploying without a commit. +# `workflow_dispatch` stays alongside it, for redeploying without a commit, +# and publish-release.yml dispatches it after every release, so the site +# names the new version and serves its Store MSI. # # The `paths:` list below is copied from site.yml rather than written as # `['site/**']`, because the build reads four files from outside site/: # CHANGELOG.md (changelog.astro imports it with ?raw), scripts/install.ps1 # and scripts/install.sh (served at /install.ps1 and /install.sh), and -# apps/netscli-gui/package.json (site-content/version.ts reads the version -# from it). A site/**-only filter would leave changes to those four built -# but never deployed -- the site would render a stale changelog or version -# with nothing failing to say so. +# apps/netscli-gui/package.json (site-content/version.ts falls back to its +# version in builds that are not told which releases are published). A +# site/**-only filter would leave changes to those four built but never +# deployed -- the site would render a stale changelog with nothing failing +# to say so. # # That makes this the same list in four places: here, site.yml's push # filter, site.yml's `changes` job grep, and site-preview.yml's two # filters. Keep them in step; YAML has no variable to share them. # +# Three jobs, so that each holds only what it needs: +# +# build npm ci and astro build, with a read-only token. This is where +# 644 packages and their install scripts run. +# stage no npm at all. Checks the built install scripts are this +# commit's, adds the Store MSIs and checks their signatures, +# then hands the site to Pages. +# deploy the only job with `pages: write` and `id-token: write`. +# # Prerequisite: in the repo Settings → Pages, set "Source" to # "GitHub Actions" (NOT "Deploy from a branch"). This workflow expects # that mode. @@ -39,10 +51,9 @@ on: - '.github/workflows/pages.yml' workflow_dispatch: +# Each job asks for what it needs. permissions: contents: read - pages: write - id-token: write # Only one deploy in flight at a time; if a newer push comes in while we're # mid-deploy, queue it and skip the one in progress. @@ -53,22 +64,114 @@ concurrency: jobs: build: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # Tags, for `check:changelog` below. It asks git which versions + # are tagged, and with the default `fetch-depth: 1` there are + # none to ask about. + fetch-depth: 0 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: # Astro 6 requires Node >=22.12. Pinning to 22 (active LTS). node-version-file: .nvmrc cache: npm cache-dependency-path: site/package-lock.json + + # The site presents the newest PUBLISHED release as the current + # version, and labels every newer CHANGELOG.md entry "Not yet + # released". Nothing in the repository can say which versions are + # published: on main a version is bumped, tagged and dated before its + # release goes public, which is how netscli.com showed the draft 0.3.5 + # as current. GitHub can, so it is asked here and the answer goes to + # the build in NETSCLI_PUBLISHED_RELEASES + # (site/src/data/site-content/version.ts). + # + # If GitHub cannot be asked, the variable is set but empty. The build + # then names no current version and dates no changelog entry, rather + # than guess, and this step warns. + - name: Ask GitHub which releases are published + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + # Drafts are invisible to this read-only token anyway; the filter + # says so. The newest 100 releases are plenty for the newest one + # and the eight the changelog page shows. + if tags=$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" \ + --jq '[.[] | select(.draft | not) | .tag_name] | join(" ")'); then + echo "Published releases: ${tags}" + else + tags="" + echo "::warning::Could not list the published releases, so this build names no current version and dates no changelog entry." + fi + echo "NETSCLI_PUBLISHED_RELEASES=${tags}" >> "$GITHUB_ENV" + - name: npm ci run: npm ci working-directory: site - name: astro build run: npm run build working-directory: site + + # The page that is about to go live dates only published releases. + # Run here, with the same NETSCLI_PUBLISHED_RELEASES, because this is + # the build that ships. site.yml runs the same check on every pull + # request, without the variable. + - name: Changelog dates + run: npm run check:changelog + working-directory: site + + - name: Hand the built site to the staging job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: site-dist + path: site/dist + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + stage: + needs: build + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # For the two install scripts and the staging script, read from this + # commit by a job that never ran npm. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Collect the built site + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: site-dist + path: site/dist + + # netscli.com/install.sh and /install.ps1 are what the site's + # one-liners pipe into a shell. The build generates them from + # scripts/install.*, in the job that ran 644 packages' code, so this + # job requires them to be byte for byte what this commit holds. + # + # It also refuses a download/ directory it did not make. Only the + # staging step below writes there, and what it writes is checked. + - name: Check the install scripts are this commit's + run: | + set -euo pipefail + for script in install.sh install.ps1; do + if ! cmp -s "scripts/${script}" "site/dist/${script}"; then + echo "::error::site/dist/${script} is not scripts/${script} at ${GITHUB_SHA}. Refusing to deploy an install script this repository did not write." + exit 1 + fi + done + if [ -e site/dist/download ]; then + echo "::error::the build produced site/dist/download, which only the Store staging step should write." + exit 1 + fi + echo "install.sh and install.ps1 match this commit." + # The Microsoft Store takes the desktop MSI by URL and refuses one that # redirects, which every GitHub release download does. So the last # few releases' MSIs are served from this site, checked against their @@ -77,19 +180,62 @@ jobs: run: bash scripts/release/stage-store-downloads.sh site/dist 3 env: GH_TOKEN: ${{ github.token }} - - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + # The checksum above comes from the same release as the MSI, so it + # proves the copy is intact, not who made it. Installers are + # Authenticode-signed from v0.3.3 on, so for those this checks the + # signature the way sign-windows checks it before upload, and that the + # signer is the project's certificate. Older releases' installers were + # never signed and are staged as they are, which is how they were + # published. + - name: Check each staged installer's signature + run: | + set -euo pipefail + sudo apt-get update || true + sudo apt-get install -y osslsigncode + ca=/etc/ssl/certs/ca-certificates.crt + shopt -s nullglob + msis=(site/dist/download/*/netscli-gui-windows-x86_64.msi) + [ ${#msis[@]} -gt 0 ] || { echo "::error::no staged installer to check"; exit 1; } + for msi in "${msis[@]}"; do + tag="$(basename "$(dirname "$msi")")" + if [ "$(printf '%s\n' v0.3.3 "$tag" | sort -V | head -n 1)" != "v0.3.3" ]; then + echo "${msi}: ${tag} predates Authenticode signing, not checked" + continue + fi + if ! out=$(osslsigncode verify -CAfile "$ca" -TSA-CAfile "$ca" "$msi" 2>&1); then + echo "$out" + echo "::error::${msi} has no valid signature" + exit 1 + fi + # osslsigncode prints the signer as + # "Subject: /C=IE/.../CN=Open Source Developer Felix Stubner", + # as in the v0.3.3 sign-windows log. + if ! grep -qF 'CN=Open Source Developer Felix Stubner' <<<"$out"; then + echo "$out" + echo "::error::${msi} is signed, but not by the project's certificate" + exit 1 + fi + echo "${msi}: signed by the project's certificate" + done + - name: Upload site/dist/ uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: site/dist deploy: - needs: build + needs: stage + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + timeout-minutes: 10 steps: + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Deploy id: deployment uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/scripts/release/stage-store-downloads.sh b/scripts/release/stage-store-downloads.sh index 6707645e..6cfe9804 100755 --- a/scripts/release/stage-store-downloads.sh +++ b/scripts/release/stage-store-downloads.sh @@ -29,14 +29,24 @@ tags=$(gh release list --repo "${repo}" --exclude-drafts --exclude-pre-releases staged=0 for tag in ${tags}; do [ "${staged}" -lt "${count}" ] || break - dir="${dist}/download/${tag}" - mkdir -p "${dir}" - if ! gh release download "${tag}" --repo "${repo}" --dir "${dir}" \ - --pattern "${asset}" --pattern "${asset}.sha256" 2>/dev/null; then + # Ask which assets the release has, rather than read a failed download as + # "this release has no MSI". That used to hide every other failure too: + # with the error output thrown away, a network or API error skipped the + # newest release, the job staged older ones and passed, and the Store's + # URL for the newest went missing with nothing failing. + names=$(gh release view "${tag}" --repo "${repo}" --json assets --jq '.assets[].name') + if ! grep -qxF "${asset}" <<<"${names}"; then echo "${tag}: no ${asset}, skipped" - rm -rf "${dir}" continue fi + if ! grep -qxF "${asset}.sha256" <<<"${names}"; then + echo "${tag}: has ${asset} but no ${asset}.sha256 to check it against" >&2 + exit 1 + fi + dir="${dist}/download/${tag}" + mkdir -p "${dir}" + gh release download "${tag}" --repo "${repo}" --dir "${dir}" \ + --pattern "${asset}" --pattern "${asset}.sha256" (cd "${dir}" && sha256sum --check --status "${asset}.sha256") || { echo "${tag}: ${asset} does not match its published checksum" >&2 exit 1 From fab49668a87584fdd25f2cb918adc4b425a8587d Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:19:46 +0100 Subject: [PATCH 12/23] preflight: check the signing credentials; lint the release PowerShell publish-preflight.yml (M24, M35) - A new `signing` job, in the release-signing environment, checks the four secrets nothing checked before a release. The updater key and its password sign a scratch file with the CLI version the app locks, and minisign verifies it against the public key in tauri.conf.json, so a wrong password or a key the app does not trust fails here instead of a quarter of an hour into the build. CERTUM_EMAIL and CERTUM_OTP are checked for presence and shape only, since a real check would log in to Certum. - WINGET_TOKEN is now also checked for the `workflow` scope (from the X-OAuth-Scopes header), which Komac needs to create its branch and which a `public_repo`-only token lacks while still authenticating. The result names the token's owner, so a later move to a bot account shows. ci.yml (M24) - PSScriptAnalyzer runs over scripts/release/*.ps1 as well as install.ps1, with the same exclusions. Not run locally: pwsh is not available in this environment, so CI is the first run. Audit M24, M35 (ci-release M-8, repo-docs 5). --- .github/workflows/ci.yml | 15 ++- .github/workflows/publish-preflight.yml | 144 ++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a9dbcb3..9d2cd848 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,7 +170,7 @@ jobs: # mistake in the first one would let an incomplete release out. - name: test release asset and updater manifest checks run: node --test scripts/release/*.test.mjs - - name: PSScriptAnalyzer install.ps1 + - name: PSScriptAnalyzer install.ps1 and the release scripts shell: pwsh # Write-Host is intentional here (colored installer UX output, which # Write-Output can't do); the empty catch blocks are documented @@ -178,10 +178,19 @@ jobs: # and BOM-less UTF-8 is the existing, working file encoding. None of # these are correctness bugs, so they're excluded rather than # rewriting a working installer for style alone. + # + # scripts/release/*.ps1 too. sign-windows-pe.ps1 and + # check-msi-signed.ps1 run only during a release, inside the Windows + # GUI build, and a failure in the first is what left v0.3.4 without + # Windows files for 1 h 38 min. Linting is the part of that path CI + # can reach without the signing credentials. run: | Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser - $results = Invoke-ScriptAnalyzer -Path scripts/install.ps1 -Severity Warning,Error ` - -ExcludeRule PSAvoidUsingWriteHost, PSAvoidUsingEmptyCatchBlock, PSUseBOMForUnicodeEncodedFile + $paths = @('scripts/install.ps1') + @(Get-ChildItem scripts/release/*.ps1 | ForEach-Object FullName) + $results = @($paths | ForEach-Object { + Invoke-ScriptAnalyzer -Path $_ -Severity Warning,Error ` + -ExcludeRule PSAvoidUsingWriteHost, PSAvoidUsingEmptyCatchBlock, PSUseBOMForUnicodeEncodedFile + }) $results | Format-Table -AutoSize if ($results.Count -gt 0) { exit 1 } diff --git a/.github/workflows/publish-preflight.yml b/.github/workflows/publish-preflight.yml index 4a76bf7d..4f287fc3 100644 --- a/.github/workflows/publish-preflight.yml +++ b/.github/workflows/publish-preflight.yml @@ -1,6 +1,7 @@ name: Publish preflight -# Checks the five publishing credentials WITHOUT publishing anything. +# Checks the five publishing credentials and the four signing credentials +# WITHOUT publishing anything. # # publish.yml pushes to each registry the moment that job succeeds, AUR has no # review step, and its own concurrency note says a run that dies halfway @@ -9,8 +10,13 @@ name: Publish preflight # most likely way to reach that state: the secrets here were last set on # 2026-05-03 and GitHub PATs commonly carry a 90-day expiry. # +# The signing credentials fail earlier and more expensively: a wrong updater +# key or password is found by the bundler, a quarter of an hour into the +# release build. The `signing` job finds it in a minute. +# # Every request below is a read. Nothing is created, forked, pushed or -# tagged. Run it before publishing a release. +# tagged. Run it before publishing a release, from main: the `signing` job +# uses the release-signing environment, which admits main and v* tags only. # # No secret value is ever echoed. The checks report HTTP status codes and # exit codes only, so a failure says which credential is bad without @@ -137,25 +143,37 @@ jobs: check_gh_repo "SCOOP_BUCKET_TOKEN" "$SCOOP_BUCKET_TOKEN" "fstubner/scoop-bucket" # ---- winget ----------------------------------------------------- - # Nobody has push on microsoft/winget-pkgs; the action forks and - # opens a PR. So validity is what matters, and the fork is reported - # for information -- its absence is fine, the action creates it. - code=$(curl -sS -o /dev/null -w '%{http_code}' \ + # Nobody has push on microsoft/winget-pkgs; Komac forks it under + # the token's owner and opens a PR from there. So validity and + # scope are what matter, and the fork is reported for information + # -- its absence is fine, Komac creates it. + # + # Scope, because a token with `public_repo` alone authenticates + # fine here and then fails mid-release when Komac creates its + # branch ("does not have the correct permissions to execute + # CreateRef"). docs/RELEASE.md says why `workflow` is needed. A + # classic token reports its scopes in X-OAuth-Scopes, and only the + # header names are printed, never the token. + headers=$(curl -sS -D - -o /dev/null \ -H "Authorization: Bearer ${WINGET_TOKEN}" -H 'Accept: application/vnd.github+json' \ - https://api.github.com/user || echo 000) + https://api.github.com/user || true) + code=$(printf '%s' "$headers" | awk 'NR==1 {print $2}') + scopes=$(printf '%s' "$headers" | tr -d '\r' | awk -F': ' 'tolower($1)=="x-oauth-scopes" {print $2}') if [ "$code" = "200" ]; then login=$(curl -sS -H "Authorization: Bearer ${WINGET_TOKEN}" \ -H 'Accept: application/vnd.github+json' https://api.github.com/user | jq -r '.login') fork=$(curl -sS -o /dev/null -w '%{http_code}' \ -H "Authorization: Bearer ${WINGET_TOKEN}" -H 'Accept: application/vnd.github+json' \ "https://api.github.com/repos/${login}/winget-pkgs" || echo 000) - if [ "$fork" = "200" ]; then - record "WINGET_TOKEN" "microsoft/winget-pkgs" 0 "valid, fork present" + if ! printf '%s' "$scopes" | tr ',' '\n' | sed 's/^ *//' | grep -qx 'workflow'; then + record "WINGET_TOKEN" "microsoft/winget-pkgs" 1 "valid for ${login}, but scopes are '${scopes:-none}', no workflow" + elif [ "$fork" = "200" ]; then + record "WINGET_TOKEN" "microsoft/winget-pkgs" 0 "valid for ${login}, fork present" else - record "WINGET_TOKEN" "microsoft/winget-pkgs" 0 "valid, no fork yet (created on publish)" + record "WINGET_TOKEN" "microsoft/winget-pkgs" 0 "valid for ${login}, no fork yet (created on publish)" fi else - record "WINGET_TOKEN" "microsoft/winget-pkgs" 1 "token rejected (HTTP $code)" + record "WINGET_TOKEN" "microsoft/winget-pkgs" 1 "token rejected (HTTP ${code:-000})" fi # ---- AUR -------------------------------------------------------- @@ -187,4 +205,106 @@ jobs: summary "**$failures credential(s) need attention before publishing.**" exit 1 fi - summary "All five credentials are good. Safe to publish." + summary "All five publishing credentials are good." + + # The four secrets in the release-signing environment. Only the jobs that + # sign can read them, so this job names the environment too. + signing: + name: Check signing credentials + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: release-signing + steps: + # For the app's public key (tauri.conf.json) and the Tauri CLI version + # the desktop app locks, both read from the branch being released. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # No secret in this step. The CLI is installed the way sign-windows + # installs it, without running any package's install scripts, before + # the next step hands it the updater key. + - name: Install the Tauri CLI and minisign + shell: bash + run: | + set -euo pipefail + version="$(node -p "require('./apps/netscli-gui/package-lock.json').packages['node_modules/@tauri-apps/cli'].version")" + npm install --prefix "${RUNNER_TEMP}/tauri-cli" --no-save --no-audit --no-fund --ignore-scripts "@tauri-apps/cli@${version}" + sudo apt-get update -qq || true + sudo apt-get install -y -qq minisign + + - name: Check all four credentials + env: + CERTUM_EMAIL: ${{ secrets.CERTUM_EMAIL }} + CERTUM_OTP: ${{ secrets.CERTUM_OTP }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + shell: bash + run: | + set -uo pipefail # NOT -e, as in the job above. + failures=0 + summary() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; echo "$1"; } + record() { # name, check, ok(0/1), detail + if [ "$3" = "0" ]; then + summary "| $1 | $2 | ✅ $4 |" + else + summary "| $1 | $2 | ❌ $4 |" + failures=$((failures + 1)) + fi + } + summary "" + summary "| Credential | Check | Result |" + summary "| --- | --- | --- |" + + # ---- Certum ----------------------------------------------------- + # Present and the right shape only. A real check means signing + # something, which logs in to Certum's cloud service, and that + # belongs to the release. The shapes are what sign-windows.sh + # accepts: an e-mail address, and a base32 TOTP seed or an + # otpauth:// URI. + if [[ "$CERTUM_EMAIL" =~ ^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$ ]]; then + record "CERTUM_EMAIL" "set, an e-mail address" 0 "ok" + else + record "CERTUM_EMAIL" "set, an e-mail address" 1 "missing or not an address" + fi + if [[ "$CERTUM_OTP" == otpauth://* || "$CERTUM_OTP" =~ ^[A-Za-z2-7=[:space:]]{16,}$ ]]; then + record "CERTUM_OTP" "set, a TOTP seed or otpauth:// URI" 0 "ok" + else + record "CERTUM_OTP" "set, a TOTP seed or otpauth:// URI" 1 "missing or not a TOTP seed" + fi + + # ---- updater key ------------------------------------------------ + # Signs a scratch file the way the release does, then verifies the + # signature against the public key the app embeds. That catches a + # wrong password, a key that is not a Tauri key, and a key that is + # not the one every installed app trusts -- the one mistake a + # release cannot recover from, because the app accepts nothing + # else. The CLI's own output stays unprinted. + name="TAURI_SIGNING_PRIVATE_KEY + _PASSWORD" + check="signs, verified against the app's public key" + if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then + record "$name" "$check" 1 "key not set" + else + scratch="$(mktemp -d)" + printf 'netscli publish preflight\n' > "${scratch}/probe" + if ! node -p "require('./apps/netscli-gui/src-tauri/tauri.conf.json').plugins.updater.pubkey" \ + | base64 -d > "${scratch}/app.pub" || [ ! -s "${scratch}/app.pub" ]; then + record "$name" "$check" 1 "could not read the app's public key from tauri.conf.json" + elif ! "${RUNNER_TEMP}/tauri-cli/node_modules/.bin/tauri" signer sign --app-version 0.0.0 \ + "${scratch}/probe" >/dev/null 2>&1; then + record "$name" "$check" 1 "cannot sign: wrong password, or not a Tauri key" + elif base64 -d "${scratch}/probe.sig" > "${scratch}/probe.minisig" && + minisign -Vm "${scratch}/probe" -p "${scratch}/app.pub" -x "${scratch}/probe.minisig" -q; then + record "$name" "$check" 0 "ok" + else + record "$name" "$check" 1 "signs, but NOT with the key the app trusts" + fi + rm -rf "$scratch" + fi + + summary "" + if [ "$failures" -gt 0 ]; then + summary "**$failures signing credential(s) need attention before publishing.**" + exit 1 + fi + summary "All four signing credentials are good." From c58ede8c8403b9f60b3d95f0769cc457968f0b67 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:20:46 +0100 Subject: [PATCH 13/23] install.sh: run everything from main(), called on the last line Structure only. Piped from curl, bash runs the script as it arrives, so a download cut short ran whatever part had arrived. The banner, the platform detection and the install itself now sit in main(), which is called on the script's last line, so a truncated download runs nothing. Every moved line is unchanged apart from its indentation (git show -w). Audit ci-release L-9 (part one). --- scripts/install.sh | 322 +++++++++++++++++++++++---------------------- 1 file changed, 166 insertions(+), 156 deletions(-) diff --git a/scripts/install.sh b/scripts/install.sh index fc65282f..2d83511f 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -53,30 +53,6 @@ is_true() { esac } -echo "NetsCLI Installer" -echo "=================" -echo "" - -# Detect OS/arch and pick the correct release asset. -OS="$(uname -s | tr '[:upper:]' '[:lower:]')" -ARCH="$(uname -m)" - -case "$OS" in - linux|darwin) ;; - *) echo "Unsupported OS: $OS"; exit 1 ;; -esac - -case "$ARCH" in - x86_64|amd64) ARCH="x86_64" ;; - aarch64|arm64) ARCH="aarch64" ;; - *) echo "Unsupported architecture: $ARCH"; exit 1 ;; -esac - -PCAP_SUFFIX="" -if is_true "$NETSCLI_PCAP"; then - PCAP_SUFFIX="-pcap" -fi - download() { local url="$1" local out="$2" @@ -218,157 +194,191 @@ install_libpcap() { return 1 } -ASSET="" -if [[ "$OS" == "linux" ]]; then - variant="$(detect_linux_variant)" - if [[ "$variant" == "musl" && -n "$PCAP_SUFFIX" ]]; then - echo "PCAP-enabled musl builds are not available." - echo "Use a glibc distro or build from source with --features pcap." - exit 1 - fi - if [[ "$variant" == "musl" && "$ARCH" != "x86_64" ]]; then - echo "Linux ${ARCH} musl release assets are not available." - echo "Use a glibc distro (NETSCLI_LINUX_VARIANT=gnu) or build from source." - exit 1 - fi - case "$variant" in - gnu) ASSET="netscli-linux-${ARCH}${PCAP_SUFFIX}" ;; - musl) ASSET="netscli-linux-${ARCH}-musl${PCAP_SUFFIX}" ;; - *) echo "Unsupported NETSCLI_LINUX_VARIANT: $variant (use 'gnu' or 'musl')"; exit 1 ;; +# Everything the installer does, in order. The rest of this file only +# defines things. +main() { + echo "NetsCLI Installer" + echo "=================" + echo "" + + # Detect OS/arch and pick the correct release asset. + OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m)" + + case "$OS" in + linux|darwin) ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; esac -else - # darwin - ASSET="netscli-macos-${ARCH}${PCAP_SUFFIX}" -fi -DOWNLOAD_BASE="https://github.com/${REPO}/releases" -if [[ -n "$NETSCLI_VERSION" ]]; then - DOWNLOAD_URL="${DOWNLOAD_BASE}/download/${NETSCLI_VERSION}/${ASSET}" -else - DOWNLOAD_URL="${DOWNLOAD_BASE}/latest/download/${ASSET}" -fi + case "$ARCH" in + x86_64|amd64) ARCH="x86_64" ;; + aarch64|arm64) ARCH="aarch64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + esac -TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t netscli)" -trap 'rm -rf "${TMP_DIR}"' EXIT - -echo "Downloading release asset: ${DOWNLOAD_URL}" -download "${DOWNLOAD_URL}" "${TMP_DIR}/${BINARY_NAME}" -# Deliberately NOT chmod +x yet — that happens only after the checksum -# check below passes, so an unverified download is never left executable. - -if [[ -n "$NETSCLI_SHA256_URL" ]]; then - echo "Fetching checksum from ${NETSCLI_SHA256_URL}" - download "${NETSCLI_SHA256_URL}" "${TMP_DIR}/${BINARY_NAME}.sha256" - NETSCLI_SHA256="$(awk '{print $1}' "${TMP_DIR}/${BINARY_NAME}.sha256" | head -n 1)" -elif [[ -z "$NETSCLI_SHA256" ]]; then - # Every release since v0.2.x publishes ".sha256" alongside the - # binary. Fetch it — and treat its absence as a failure, not as - # permission to skip verification. An attacker on the path can always - # make one request fail; if that silently downgraded us to "install - # unverified", the checksum would be worth nothing. - if ! download_optional "${DOWNLOAD_URL}.sha256" "${TMP_DIR}/${BINARY_NAME}.sha256"; then - echo "ERROR: could not fetch ${DOWNLOAD_URL}.sha256" >&2 - echo "Refusing to install an unverified binary." >&2 - echo "" >&2 - echo "If this release genuinely has no checksum asset, supply one explicitly:" >&2 - echo " NETSCLI_SHA256= ... # known digest" >&2 - echo " NETSCLI_SHA256_URL= ... # digest fetched from elsewhere" >&2 - echo " NETSCLI_ALLOW_UNVERIFIED=1 ... # opt out (not recommended)" >&2 - exit 1 + PCAP_SUFFIX="" + if is_true "$NETSCLI_PCAP"; then + PCAP_SUFFIX="-pcap" fi - NETSCLI_SHA256="$(awk '{print $1}' "${TMP_DIR}/${BINARY_NAME}.sha256" | head -n 1)" -fi -if [[ -n "$NETSCLI_SHA256" ]]; then - if [[ ! "$NETSCLI_SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then - echo "ERROR: checksum is not a 64-character hex digest: '${NETSCLI_SHA256}'" >&2 - exit 1 + ASSET="" + if [[ "$OS" == "linux" ]]; then + variant="$(detect_linux_variant)" + if [[ "$variant" == "musl" && -n "$PCAP_SUFFIX" ]]; then + echo "PCAP-enabled musl builds are not available." + echo "Use a glibc distro or build from source with --features pcap." + exit 1 + fi + if [[ "$variant" == "musl" && "$ARCH" != "x86_64" ]]; then + echo "Linux ${ARCH} musl release assets are not available." + echo "Use a glibc distro (NETSCLI_LINUX_VARIANT=gnu) or build from source." + exit 1 + fi + case "$variant" in + gnu) ASSET="netscli-linux-${ARCH}${PCAP_SUFFIX}" ;; + musl) ASSET="netscli-linux-${ARCH}-musl${PCAP_SUFFIX}" ;; + *) echo "Unsupported NETSCLI_LINUX_VARIANT: $variant (use 'gnu' or 'musl')"; exit 1 ;; + esac + else + # darwin + ASSET="netscli-macos-${ARCH}${PCAP_SUFFIX}" fi - echo "Verifying checksum..." - if have_cmd sha256sum; then - echo "${NETSCLI_SHA256} ${TMP_DIR}/${BINARY_NAME}" | sha256sum -c - - elif have_cmd shasum; then - echo "${NETSCLI_SHA256} ${TMP_DIR}/${BINARY_NAME}" | shasum -a 256 -c - + + DOWNLOAD_BASE="https://github.com/${REPO}/releases" + if [[ -n "$NETSCLI_VERSION" ]]; then + DOWNLOAD_URL="${DOWNLOAD_BASE}/download/${NETSCLI_VERSION}/${ASSET}" else - echo "sha256sum or shasum not found; cannot verify checksum." >&2 + DOWNLOAD_URL="${DOWNLOAD_BASE}/latest/download/${ASSET}" + fi + + TMP_DIR="$(mktemp -d 2>/dev/null || mktemp -d -t netscli)" + trap 'rm -rf "${TMP_DIR}"' EXIT + + echo "Downloading release asset: ${DOWNLOAD_URL}" + download "${DOWNLOAD_URL}" "${TMP_DIR}/${BINARY_NAME}" + # Deliberately NOT chmod +x yet — that happens only after the checksum + # check below passes, so an unverified download is never left executable. + + if [[ -n "$NETSCLI_SHA256_URL" ]]; then + echo "Fetching checksum from ${NETSCLI_SHA256_URL}" + download "${NETSCLI_SHA256_URL}" "${TMP_DIR}/${BINARY_NAME}.sha256" + NETSCLI_SHA256="$(awk '{print $1}' "${TMP_DIR}/${BINARY_NAME}.sha256" | head -n 1)" + elif [[ -z "$NETSCLI_SHA256" ]]; then + # Every release since v0.2.x publishes ".sha256" alongside the + # binary. Fetch it — and treat its absence as a failure, not as + # permission to skip verification. An attacker on the path can always + # make one request fail; if that silently downgraded us to "install + # unverified", the checksum would be worth nothing. + if ! download_optional "${DOWNLOAD_URL}.sha256" "${TMP_DIR}/${BINARY_NAME}.sha256"; then + echo "ERROR: could not fetch ${DOWNLOAD_URL}.sha256" >&2 + echo "Refusing to install an unverified binary." >&2 + echo "" >&2 + echo "If this release genuinely has no checksum asset, supply one explicitly:" >&2 + echo " NETSCLI_SHA256= ... # known digest" >&2 + echo " NETSCLI_SHA256_URL= ... # digest fetched from elsewhere" >&2 + echo " NETSCLI_ALLOW_UNVERIFIED=1 ... # opt out (not recommended)" >&2 + exit 1 + fi + NETSCLI_SHA256="$(awk '{print $1}' "${TMP_DIR}/${BINARY_NAME}.sha256" | head -n 1)" + fi + + if [[ -n "$NETSCLI_SHA256" ]]; then + if [[ ! "$NETSCLI_SHA256" =~ ^[0-9a-fA-F]{64}$ ]]; then + echo "ERROR: checksum is not a 64-character hex digest: '${NETSCLI_SHA256}'" >&2 + exit 1 + fi + echo "Verifying checksum..." + if have_cmd sha256sum; then + echo "${NETSCLI_SHA256} ${TMP_DIR}/${BINARY_NAME}" | sha256sum -c - + elif have_cmd shasum; then + echo "${NETSCLI_SHA256} ${TMP_DIR}/${BINARY_NAME}" | shasum -a 256 -c - + else + echo "sha256sum or shasum not found; cannot verify checksum." >&2 + exit 1 + fi + elif is_true "${NETSCLI_ALLOW_UNVERIFIED:-}"; then + echo "Warning: installing WITHOUT checksum verification (NETSCLI_ALLOW_UNVERIFIED=1)." + else + echo "ERROR: no checksum available and NETSCLI_ALLOW_UNVERIFIED is not set." >&2 exit 1 fi -elif is_true "${NETSCLI_ALLOW_UNVERIFIED:-}"; then - echo "Warning: installing WITHOUT checksum verification (NETSCLI_ALLOW_UNVERIFIED=1)." -else - echo "ERROR: no checksum available and NETSCLI_ALLOW_UNVERIFIED is not set." >&2 - exit 1 -fi -# Verified (or explicitly waived) — safe to make executable now. -chmod +x "${TMP_DIR}/${BINARY_NAME}" + # Verified (or explicitly waived) — safe to make executable now. + chmod +x "${TMP_DIR}/${BINARY_NAME}" -if [[ "$OS" == "linux" && "${ASSET}" == *"-musl" ]]; then - echo "" - echo "NOTE: You are installing the musl build." - echo " PCAP capture is disabled in the musl release artifact." - echo " If you need PCAP, use a glibc distro or build from source." -fi + if [[ "$OS" == "linux" && "${ASSET}" == *"-musl" ]]; then + echo "" + echo "NOTE: You are installing the musl build." + echo " PCAP capture is disabled in the musl release artifact." + echo " If you need PCAP, use a glibc distro or build from source." + fi -if ! is_true "$NETSCLI_PCAP"; then - echo "" - echo "NOTE: Default release builds do not include packet capture." - echo " Set NETSCLI_PCAP=1 to install the PCAP-enabled release asset." -fi + if ! is_true "$NETSCLI_PCAP"; then + echo "" + echo "NOTE: Default release builds do not include packet capture." + echo " Set NETSCLI_PCAP=1 to install the PCAP-enabled release asset." + fi -echo "Installing to ${INSTALL_DIR}..." -mkdir -p "${INSTALL_DIR}" -cp "${TMP_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" -chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + echo "Installing to ${INSTALL_DIR}..." + mkdir -p "${INSTALL_DIR}" + cp "${TMP_DIR}/${BINARY_NAME}" "${INSTALL_DIR}/${BINARY_NAME}" + chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + + case ":${PATH}:" in + *":${INSTALL_DIR}:"*) ;; + *) + echo "" + echo "NOTE: ${INSTALL_DIR} is not in your PATH." + echo "Add this to your shell profile:" + echo " export PATH=\"\$PATH:${INSTALL_DIR}\"" + ;; + esac -case ":${PATH}:" in - *":${INSTALL_DIR}:"*) ;; - *) + # The libpcap step runs BEFORE the success message, and the message reports + # what actually happened. It used to be the other way round: "Installed + # successfully." printed first, then a non-fatal libpcap attempt underneath + # it. A user who set NETSCLI_PCAP=1 because they wanted packet capture read + # the success line and got a binary that cannot capture. + CAPTURE_READY=1 + if is_true "$NETSCLI_PCAP"; then echo "" - echo "NOTE: ${INSTALL_DIR} is not in your PATH." - echo "Add this to your shell profile:" - echo " export PATH=\"\$PATH:${INSTALL_DIR}\"" - ;; -esac - -# The libpcap step runs BEFORE the success message, and the message reports -# what actually happened. It used to be the other way round: "Installed -# successfully." printed first, then a non-fatal libpcap attempt underneath -# it. A user who set NETSCLI_PCAP=1 because they wanted packet capture read -# the success line and got a binary that cannot capture. -CAPTURE_READY=1 -if is_true "$NETSCLI_PCAP"; then - echo "" - if have_libpcap; then - echo "libpcap detected — no system install needed." - elif is_true "$NETSCLI_SKIP_LIBPCAP"; then - echo "libpcap not detected; skipping install as requested (NETSCLI_SKIP_LIBPCAP=1)." - else - echo "libpcap not detected. Attempting installation..." - if install_libpcap; then - echo "libpcap installation completed." + if have_libpcap; then + echo "libpcap detected — no system install needed." + elif is_true "$NETSCLI_SKIP_LIBPCAP"; then + echo "libpcap not detected; skipping install as requested (NETSCLI_SKIP_LIBPCAP=1)." else - CAPTURE_READY=0 - echo "libpcap installation failed — install it manually:" - if [[ "$OS" == "darwin" ]]; then - echo " brew install libpcap tcpdump" + echo "libpcap not detected. Attempting installation..." + if install_libpcap; then + echo "libpcap installation completed." else - echo " sudo apt-get install -y libpcap-dev tcpdump # (or your distro's equivalent)" + CAPTURE_READY=0 + echo "libpcap installation failed — install it manually:" + if [[ "$OS" == "darwin" ]]; then + echo " brew install libpcap tcpdump" + else + echo " sudo apt-get install -y libpcap-dev tcpdump # (or your distro's equivalent)" + fi + echo "Or run: netscli setup" fi - echo "Or run: netscli setup" fi fi -fi -echo "" -if [[ "$CAPTURE_READY" -eq 1 ]]; then - echo "Installed successfully." -else - echo "Installed ${BINARY_NAME}, but packet capture is NOT ready." - echo "Install libpcap as described above, then re-check with: ${BINARY_NAME} doctor" -fi + echo "" + if [[ "$CAPTURE_READY" -eq 1 ]]; then + echo "Installed successfully." + else + echo "Installed ${BINARY_NAME}, but packet capture is NOT ready." + echo "Install libpcap as described above, then re-check with: ${BINARY_NAME} doctor" + fi + + echo "Try:" + echo " ${BINARY_NAME} --help" + echo " ${BINARY_NAME} doctor --json" + echo " ${BINARY_NAME} serve" +} -echo "Try:" -echo " ${BINARY_NAME} --help" -echo " ${BINARY_NAME} doctor --json" -echo " ${BINARY_NAME} serve" +# Nothing above this line does more than set variables and define +# functions. Piped from curl, bash runs a script as it arrives, so a +# download cut short used to run whatever had arrived. Now a cut anywhere +# above leaves main() undefined or never called, and nothing is installed. +main "$@" From 346e0e77f8b27732d6298c12775dc1d3dc282899 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:24:45 +0100 Subject: [PATCH 14/23] install.sh: check the Sigstore signature when cosign is there; HTTPS only M20: the installer checked only a SHA-256 fetched from the same release as the binary, which proves the download is intact but not who built it. With cosign installed it now also verifies the binary's .sig/.pem, and requires the signer to be release.yml in this repository on main or a release tag (not the looser `release\.yml@.*`, which accepts a signature made by that file on any branch). A signature that is missing or does not verify stops the install. Without cosign it installs as before. Either way it prints what it checked and what it did not. NETSCLI_SKIP_SIGNATURE=1 is the documented way past the check. L-9: curl now runs with --proto '=https' --proto-redir '=https' --tlsv1.2. Every GitHub release download is a redirect, and curl follows redirects to plain HTTP by default. install_test.sh runs it against a fake release (fake curl, uname and cosign, no network), and CI runs it in script-lint. All 11 cases pass; run against the original install.sh, 8 of them fail. Audit M20 (ci-release M-4), ci-release L-9, L-11. --- .github/workflows/ci.yml | 7 +- scripts/install.sh | 79 +++++++++++++++- scripts/install_test.sh | 190 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 scripts/install_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9d2cd848..7be7e458 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,7 +148,12 @@ jobs: # push to three package registries. Both are worth linting, and # neither is exercised by any other job. - name: shellcheck install.sh - run: shellcheck scripts/install.sh + run: shellcheck scripts/install.sh scripts/install_test.sh + # What install.sh checks before it installs, against a fake release: + # a wrong checksum or a signature that does not verify must stop it, + # and a download cut short must run nothing. + - name: test install.sh + run: bash scripts/install_test.sh - name: shellcheck release scripts run: shellcheck scripts/release/*.sh # The publish path only executes during a release, so a regression in diff --git a/scripts/install.sh b/scripts/install.sh index 2d83511f..dc95a7dc 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -16,10 +16,22 @@ # the release and REFUSES to install if it cannot be verified. # - NETSCLI_ALLOW_UNVERIFIED=1: install without checksum verification # (not recommended; only for releases that publish no checksum asset) +# - NETSCLI_SKIP_SIGNATURE=1: do not check the Sigstore signature even when +# cosign is installed (see below) # - NETSCLI_PCAP=1: install the PCAP-enabled binary AND libpcap system lib # - NETSCLI_SKIP_LIBPCAP=1: with NETSCLI_PCAP=1, skip installing libpcap # (for users who already manage libpcap themselves) # - NETSCLI_LINUX_VARIANT=gnu|musl (Linux only; default: auto-detect) +# +# What is verified before anything is installed: +# - Always: the binary's SHA-256, against ".sha256" from the same +# release (or NETSCLI_SHA256 / NETSCLI_SHA256_URL). That proves the +# download is intact, not who built it, since both come from one place. +# - When cosign is installed: the binary's Sigstore signature +# (".sig" and ".pem"), which must have been made by this +# repository's release workflow, release.yml, on main or a release tag. +# A signature that does not verify stops the install. +# The script says which of the two it checked. set -euo pipefail @@ -53,12 +65,17 @@ is_true() { esac } +# HTTPS only, TLS 1.2 or newer, also after a redirect. GitHub answers every +# release download with a redirect, and by default curl would follow one to +# plain HTTP. +CURL_OPTS=(--proto '=https' --proto-redir '=https' --tlsv1.2 -fsSL) + download() { local url="$1" local out="$2" if have_cmd curl; then - curl -fsSL "$url" -o "$out" + curl "${CURL_OPTS[@]}" "$url" -o "$out" return 0 fi @@ -77,7 +94,7 @@ download_optional() { local out="$2" if have_cmd curl; then - if curl -fsSL "$url" -o "$out"; then + if curl "${CURL_OPTS[@]}" "$url" -o "$out"; then return 0 fi return 1 @@ -194,6 +211,57 @@ install_libpcap() { return 1 } +# The checksum comes from the same release as the binary, so it proves the +# download is intact but not who built it. Every CLI asset also carries a +# Sigstore signature, made by this repository's release workflow, and with +# cosign installed that is checked too, failing closed: a signature that is +# missing or does not verify stops the install. Without cosign, the script +# keeps working and says exactly what it did and did not check. +# +# $1 is what the checksum was checked against, or empty if it was waived. +verify_signature() { + local sum_source="$1" repo_re identity + local checked="${sum_source:-nothing (NETSCLI_ALLOW_UNVERIFIED=1)}" + + if is_true "${NETSCLI_SKIP_SIGNATURE:-}"; then + echo "Checked: ${checked}. Not checked: the Sigstore signature (NETSCLI_SKIP_SIGNATURE=1)." + return 0 + fi + if ! have_cmd cosign; then + echo "Checked: ${checked}. Not checked: the Sigstore signature, because cosign is not installed." + return 0 + fi + + if ! download_optional "${DOWNLOAD_URL}.sig" "${TMP_DIR}/${BINARY_NAME}.sig" || + ! download_optional "${DOWNLOAD_URL}.pem" "${TMP_DIR}/${BINARY_NAME}.pem"; then + echo "ERROR: cosign is installed, but ${DOWNLOAD_URL}.sig or .pem could not be fetched." >&2 + echo "Refusing to install a binary whose signature cannot be checked." >&2 + echo "To install anyway: NETSCLI_SKIP_SIGNATURE=1" >&2 + exit 1 + fi + + # The one workflow that signs release assets: release.yml in this + # repository, run from main, or from a release tag before v0.3.1. Not + # `release\.yml@.*`, which would accept a signature made by that file on + # any branch someone with write access pushes. Owner and repository names + # can hold no regex metacharacter but the dot. + repo_re="${REPO//./\\.}" + identity='^https://github\.com/'"${repo_re}"'/\.github/workflows/release\.yml@refs/(heads/main|tags/v[0-9.]+)$' + echo "Verifying the Sigstore signature..." + if ! cosign verify-blob \ + --signature "${TMP_DIR}/${BINARY_NAME}.sig" \ + --certificate "${TMP_DIR}/${BINARY_NAME}.pem" \ + --certificate-identity-regexp "$identity" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${TMP_DIR}/${BINARY_NAME}"; then + echo "ERROR: the Sigstore signature did not verify. Refusing to install this binary." >&2 + echo "If cosign failed for a reason you know, such as no network access to" >&2 + echo "Sigstore, NETSCLI_SKIP_SIGNATURE=1 skips this check." >&2 + exit 1 + fi + echo "Checked: ${checked}, and the Sigstore signature from ${REPO}'s release workflow." +} + # Everything the installer does, in order. The rest of this file only # defines things. main() { @@ -259,11 +327,15 @@ main() { # Deliberately NOT chmod +x yet — that happens only after the checksum # check below passes, so an unverified download is never left executable. + # Where the expected digest came from, for the summary of what was checked. + SUM_SOURCE="the SHA-256 given in NETSCLI_SHA256" if [[ -n "$NETSCLI_SHA256_URL" ]]; then echo "Fetching checksum from ${NETSCLI_SHA256_URL}" download "${NETSCLI_SHA256_URL}" "${TMP_DIR}/${BINARY_NAME}.sha256" NETSCLI_SHA256="$(awk '{print $1}' "${TMP_DIR}/${BINARY_NAME}.sha256" | head -n 1)" + SUM_SOURCE="the SHA-256 from NETSCLI_SHA256_URL" elif [[ -z "$NETSCLI_SHA256" ]]; then + SUM_SOURCE="the SHA-256 published with this release" # Every release since v0.2.x publishes ".sha256" alongside the # binary. Fetch it — and treat its absence as a failure, not as # permission to skip verification. An attacker on the path can always @@ -298,11 +370,14 @@ main() { fi elif is_true "${NETSCLI_ALLOW_UNVERIFIED:-}"; then echo "Warning: installing WITHOUT checksum verification (NETSCLI_ALLOW_UNVERIFIED=1)." + SUM_SOURCE="" else echo "ERROR: no checksum available and NETSCLI_ALLOW_UNVERIFIED is not set." >&2 exit 1 fi + verify_signature "$SUM_SOURCE" + # Verified (or explicitly waived) — safe to make executable now. chmod +x "${TMP_DIR}/${BINARY_NAME}" diff --git a/scripts/install_test.sh b/scripts/install_test.sh new file mode 100644 index 00000000..6b85b23d --- /dev/null +++ b/scripts/install_test.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# Tests for install.sh against a fake release. +# +# install.sh is what netscli.com's one-liner pipes into bash, and what it +# checks before installing is exactly where a mistake hides: a check that +# passes when it should refuse looks the same as one that works. A fake +# `curl` serves a release from a temporary directory, and fake `uname` and +# `cosign` stand in for the real ones, so nothing here touches the network. +# +# Run: bash scripts/install_test.sh + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +failures=0 +ok() { echo " ok - $1"; } +bad() { echo " FAIL - $1" >&2; failures=$((failures + 1)); } + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +# --- the fake release ------------------------------------------------------- + +ASSET="netscli-linux-x86_64" +RELEASE="$TMP/release" +mkdir -p "$RELEASE" "$TMP/bin" "$TMP/cosign-bin" +printf 'fake netscli binary\n' >"$RELEASE/$ASSET" +printf '%s %s\n' "$(sha256_of "$RELEASE/$ASSET")" "$ASSET" >"$RELEASE/$ASSET.sha256" +printf 'fake signature\n' >"$RELEASE/$ASSET.sig" +printf 'fake certificate\n' >"$RELEASE/$ASSET.pem" + +# Serves any URL from $FAKE_RELEASE by its last path segment, and fails the +# way `curl -f` does when there is no such file. Every call is logged. +cat >"$TMP/bin/curl" <<'EOF' +#!/usr/bin/env bash +echo "curl $*" >>"$FAKE_LOG" +out="" url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift ;; + https://*|http://*) url="$1" ;; + esac + shift +done +src="$FAKE_RELEASE/${url##*/}" +[ -f "$src" ] || exit 22 +if [ -n "$out" ]; then cp "$src" "$out"; else cat "$src"; fi +EOF + +# install.sh only installs on Linux and macOS, and the test also runs on +# Windows under Git Bash, so the platform is fixed here. +cat >"$TMP/bin/uname" <<'EOF' +#!/usr/bin/env bash +case "$1" in + -s) echo Linux ;; + -m) echo x86_64 ;; + *) echo Linux ;; +esac +EOF + +# Logs its arguments and answers with $FAKE_COSIGN_EXIT. +cat >"$TMP/cosign-bin/cosign" <<'EOF' +#!/usr/bin/env bash +echo "cosign $*" >>"$FAKE_LOG" +exit "${FAKE_COSIGN_EXIT:-0}" +EOF +chmod +x "$TMP/bin/curl" "$TMP/bin/uname" "$TMP/cosign-bin/cosign" + +# Runs a script (install.sh by default) with the fake release. Sets $status +# and $out, and leaves the call log in $FAKE_LOG and the install in $DEST. +# Extra arguments are environment assignments; COSIGN=1 puts the fake +# cosign on PATH. +run() { + local script="$HERE/install.sh" path="$TMP/bin:$PATH" + local -a env_args=() + local arg + for arg in "$@"; do + case "$arg" in + COSIGN=1) path="$TMP/cosign-bin:$path" ;; + SCRIPT=*) script="${arg#SCRIPT=}" ;; + *) env_args+=("$arg") ;; + esac + done + DEST="$(mktemp -d "$TMP/install.XXXX")" + FAKE_LOG="$DEST.log" + : >"$FAKE_LOG" + set +e + out=$(env PATH="$path" FAKE_RELEASE="$RELEASE" FAKE_LOG="$FAKE_LOG" \ + INSTALL_DIR="$DEST" NETSCLI_LINUX_VARIANT=gnu "${env_args[@]}" \ + bash "$script" 2>&1) + status=$? + set -e +} + +installed() { cmp -s "$RELEASE/$ASSET" "$DEST/netscli"; } + +# The harness itself: a cosign already on this machine would make the +# "without cosign" cases test nothing. +if env PATH="$TMP/bin:$PATH" bash -c 'command -v cosign' >/dev/null 2>&1; then + bad "setup: a real cosign is on PATH, so the cases without cosign prove nothing" +fi + +echo "install.sh" + +# --- checksum --------------------------------------------------------------- + +run +if [ "$status" = 0 ] && installed; then ok "installs a binary whose checksum matches"; else bad "good release: status $status, $out"; fi +if grep -q "Not checked: the Sigstore signature, because cosign is not installed" <<<"$out" && + grep -q "Checked: the SHA-256 published with this release" <<<"$out"; then + ok "says what it checked and what it did not, without cosign" +else + bad "summary without cosign: $out" +fi +if grep -q -- "--proto =https --proto-redir =https --tlsv1.2" "$FAKE_LOG"; then + ok "downloads over HTTPS only, redirects included" +else + bad "curl options: $(cat "$FAKE_LOG")" +fi + +cp "$RELEASE/$ASSET.sha256" "$TMP/good.sha256" +printf 'some other build\n' >"$TMP/other" +printf '%s %s\n' "$(sha256_of "$TMP/other")" "$ASSET" >"$RELEASE/$ASSET.sha256" +run +if [ "$status" != 0 ] && ! installed; then ok "refuses a binary whose checksum does not match"; else bad "bad checksum: status $status, $out"; fi +cp "$TMP/good.sha256" "$RELEASE/$ASSET.sha256" + +# --- signature ---------------------------------------------------------------- + +run COSIGN=1 +if [ "$status" = 0 ] && installed && grep -q "and the Sigstore signature" <<<"$out"; then + ok "installs when cosign verifies the signature" +else + bad "cosign verifies: status $status, $out" +fi +# The single quotes are the point: this is the literal regexp cosign must get. +# shellcheck disable=SC2016 +want='--certificate-identity-regexp ^https://github\.com/fstubner/netscli/\.github/workflows/release\.yml@refs/(heads/main|tags/v[0-9.]+)$ --certificate-oidc-issuer https://token.actions.githubusercontent.com' +if grep -qF -- "$want" "$FAKE_LOG"; then + ok "asks cosign for release.yml on main or a release tag, and nothing looser" +else + bad "cosign arguments: $(grep cosign "$FAKE_LOG" || true)" +fi + +run COSIGN=1 FAKE_COSIGN_EXIT=1 +if [ "$status" != 0 ] && ! installed; then ok "refuses when the signature does not verify"; else bad "cosign fails: status $status, $out"; fi + +mv "$RELEASE/$ASSET.sig" "$TMP/kept.sig" +run COSIGN=1 +if [ "$status" != 0 ] && ! installed; then ok "refuses when the signature cannot be fetched"; else bad "no .sig: status $status, $out"; fi +mv "$TMP/kept.sig" "$RELEASE/$ASSET.sig" + +run COSIGN=1 FAKE_COSIGN_EXIT=1 NETSCLI_SKIP_SIGNATURE=1 +if [ "$status" = 0 ] && installed && ! grep -q "^cosign" "$FAKE_LOG"; then + ok "NETSCLI_SKIP_SIGNATURE=1 skips the signature and says so" +else + bad "skip: status $status, $out" +fi + +# --- a download cut short ----------------------------------------------------- +# +# Piped from curl, bash runs what has arrived. Both cuts below must install +# nothing and download nothing. + +head -n "$(($(wc -l <"$HERE/install.sh") - 1))" "$HERE/install.sh" >"$TMP/cut-before-call.sh" +run SCRIPT="$TMP/cut-before-call.sh" +if ! installed && [ ! -s "$FAKE_LOG" ]; then ok "a script cut before its last line does nothing"; else bad "cut before call: status $status, $out"; fi + +main_line=$(grep -n '^main() {' "$HERE/install.sh" | cut -d: -f1 || true) +if [ -z "$main_line" ]; then + bad "install.sh has no main() to cut inside" +else + head -n "$((main_line + 30))" "$HERE/install.sh" >"$TMP/cut-inside-main.sh" + run SCRIPT="$TMP/cut-inside-main.sh" + if ! installed && [ ! -s "$FAKE_LOG" ]; then ok "a script cut inside main() does nothing"; else bad "cut inside main: status $status, $out"; fi +fi + +if [ "$failures" -gt 0 ]; then + echo "$failures failed" >&2 + exit 1 +fi +echo "all install.sh tests passed" From 72eaadc15db1552ba18c4421b5e4050b9af7a473 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:26:28 +0100 Subject: [PATCH 15/23] install.ps1: run from a function and throw instead of exit Every failure path called `exit 1`. The documented invocation is `iwr ... | iex`, which runs the script inside the caller's own PowerShell session, so `exit` ended that session and closed the window with the error message in it. The top-level variables and $ErrorActionPreference = 'Stop' were also left behind in the session. The procedural part now runs in Install-NetsCLI, called on the last line, and each `exit 1` is a `throw` naming what went wrong, after the same red messages as before. A thrown error leaves an interactive window open, and still ends `powershell -File` or `-Command` with exit code 1. Every other moved line is unchanged apart from its indentation (git show -w). Not run locally: pwsh is not available in this environment, so CI's PSScriptAnalyzer step is the first check. Audit ci-release L-8. --- scripts/install.ps1 | 289 +++++++++++++++++++++++--------------------- 1 file changed, 151 insertions(+), 138 deletions(-) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 6d23688f..e47fce85 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -21,30 +21,6 @@ # - NETSCLI_ALLOW_UNVERIFIED=1: install without checksum verification # (not recommended; only for releases that publish no checksum asset) -$ErrorActionPreference = "Stop" - -$INSTALL_DIR = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:USERPROFILE\.cargo\bin" } -$BINARY_NAME = "netscli.exe" -$REPO = if ($env:REPO) { $env:REPO } else { "fstubner/netscli" } -$NETSCLI_VERSION = if ($env:NETSCLI_VERSION) { $env:NETSCLI_VERSION } else { "" } -$NETSCLI_SHA256 = if ($env:NETSCLI_SHA256) { $env:NETSCLI_SHA256 } else { "" } -$NETSCLI_SHA256_URL = if ($env:NETSCLI_SHA256_URL) { $env:NETSCLI_SHA256_URL } else { "" } -$NETSCLI_PCAP = if ($env:NETSCLI_PCAP) { $env:NETSCLI_PCAP } else { "" } -$NETSCLI_SKIP_NPCAP = if ($env:NETSCLI_SKIP_NPCAP) { $env:NETSCLI_SKIP_NPCAP } else { "" } -$NETSCLI_ALLOW_UNVERIFIED = if ($env:NETSCLI_ALLOW_UNVERIFIED) { $env:NETSCLI_ALLOW_UNVERIFIED } else { "" } - -# Backwards-compat: older docs used NETSCLI_INSTALL_NPCAP as a separate toggle. -# If the user set it, fold it into NETSCLI_PCAP so the old invocation works. -if (-not $NETSCLI_PCAP -and $env:NETSCLI_INSTALL_NPCAP) { - $NETSCLI_PCAP = $env:NETSCLI_INSTALL_NPCAP -} - -$NETSCLI_NPCAP_URL = if ($env:NETSCLI_NPCAP_URL) { $env:NETSCLI_NPCAP_URL } else { "https://npcap.com/dist/npcap-1.80.exe" } - -Write-Host "NetsCLI Installer" -ForegroundColor Cyan -Write-Host "=================" -ForegroundColor Cyan -Write-Host "" - # Ensure TLS 1.2 for older Windows PowerShell try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 @@ -153,147 +129,184 @@ function Install-Npcap([string]$downloadDir, [string]$url) { return $proc.ExitCode -eq 0 } -$arch = $env:PROCESSOR_ARCHITECTURE -if ($arch -ne "AMD64") { - Write-Host "Unsupported architecture: $arch (expected AMD64)." -ForegroundColor Red - exit 1 -} +# Everything the installer does. In a function so that its variables and +# its $ErrorActionPreference stay inside it: run as `iwr ... | iex`, the +# script runs in the caller's own PowerShell session, and top-level +# assignments were left behind there. +# +# A failure throws rather than calling `exit`. Under `iex`, `exit` ended the +# caller's session, closing the window and the error message with it. A +# thrown error leaves the window open, and still ends `powershell -File` +# or `-Command` with exit code 1. +function Install-NetsCLI { + $ErrorActionPreference = "Stop" + + $INSTALL_DIR = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:USERPROFILE\.cargo\bin" } + $BINARY_NAME = "netscli.exe" + $REPO = if ($env:REPO) { $env:REPO } else { "fstubner/netscli" } + $NETSCLI_VERSION = if ($env:NETSCLI_VERSION) { $env:NETSCLI_VERSION } else { "" } + $NETSCLI_SHA256 = if ($env:NETSCLI_SHA256) { $env:NETSCLI_SHA256 } else { "" } + $NETSCLI_SHA256_URL = if ($env:NETSCLI_SHA256_URL) { $env:NETSCLI_SHA256_URL } else { "" } + $NETSCLI_PCAP = if ($env:NETSCLI_PCAP) { $env:NETSCLI_PCAP } else { "" } + $NETSCLI_SKIP_NPCAP = if ($env:NETSCLI_SKIP_NPCAP) { $env:NETSCLI_SKIP_NPCAP } else { "" } + $NETSCLI_ALLOW_UNVERIFIED = if ($env:NETSCLI_ALLOW_UNVERIFIED) { $env:NETSCLI_ALLOW_UNVERIFIED } else { "" } + + # Backwards-compat: older docs used NETSCLI_INSTALL_NPCAP as a separate toggle. + # If the user set it, fold it into NETSCLI_PCAP so the old invocation works. + if (-not $NETSCLI_PCAP -and $env:NETSCLI_INSTALL_NPCAP) { + $NETSCLI_PCAP = $env:NETSCLI_INSTALL_NPCAP + } -$ENABLE_PCAP = Test-True $NETSCLI_PCAP -# `NETSCLI_PCAP=1` is the one user-facing knob; it both selects the pcap -# binary variant AND triggers Npcap installation. `NETSCLI_SKIP_NPCAP=1` -# lets users opt out of the Npcap step (e.g., Npcap already installed). -$SKIP_NPCAP = Test-True $NETSCLI_SKIP_NPCAP + $NETSCLI_NPCAP_URL = if ($env:NETSCLI_NPCAP_URL) { $env:NETSCLI_NPCAP_URL } else { "https://npcap.com/dist/npcap-1.80.exe" } -$ASSET_NAME = if ($ENABLE_PCAP) { "netscli-windows-x86_64-pcap.exe" } else { "netscli-windows-x86_64.exe" } + Write-Host "NetsCLI Installer" -ForegroundColor Cyan + Write-Host "=================" -ForegroundColor Cyan + Write-Host "" -$DOWNLOAD_BASE = "https://github.com/$REPO/releases" -if ($NETSCLI_VERSION -and $NETSCLI_VERSION.Trim().Length -gt 0) { - $DOWNLOAD_URL = "$DOWNLOAD_BASE/download/$NETSCLI_VERSION/$ASSET_NAME" -} else { - $DOWNLOAD_URL = "$DOWNLOAD_BASE/latest/download/$ASSET_NAME" -} + $arch = $env:PROCESSOR_ARCHITECTURE + if ($arch -ne "AMD64") { + Write-Host "Unsupported architecture: $arch (expected AMD64)." -ForegroundColor Red + throw "NetsCLI was not installed: unsupported architecture $arch." + } -$tmpDir = Join-Path $env:TEMP ("netscli-install-" + [guid]::NewGuid().ToString()) -New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + $ENABLE_PCAP = Test-True $NETSCLI_PCAP + # `NETSCLI_PCAP=1` is the one user-facing knob; it both selects the pcap + # binary variant AND triggers Npcap installation. `NETSCLI_SKIP_NPCAP=1` + # lets users opt out of the Npcap step (e.g., Npcap already installed). + $SKIP_NPCAP = Test-True $NETSCLI_SKIP_NPCAP -try { - $tmpExe = Join-Path $tmpDir $BINARY_NAME + $ASSET_NAME = if ($ENABLE_PCAP) { "netscli-windows-x86_64-pcap.exe" } else { "netscli-windows-x86_64.exe" } + + $DOWNLOAD_BASE = "https://github.com/$REPO/releases" + if ($NETSCLI_VERSION -and $NETSCLI_VERSION.Trim().Length -gt 0) { + $DOWNLOAD_URL = "$DOWNLOAD_BASE/download/$NETSCLI_VERSION/$ASSET_NAME" + } else { + $DOWNLOAD_URL = "$DOWNLOAD_BASE/latest/download/$ASSET_NAME" + } - Write-Host "Downloading release asset:" -ForegroundColor Cyan - Write-Host " $DOWNLOAD_URL" -ForegroundColor DarkGray + $tmpDir = Join-Path $env:TEMP ("netscli-install-" + [guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null try { - Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $tmpExe - } catch { - Write-Host "Download failed." -ForegroundColor Red - Write-Host $_.Exception.Message -ForegroundColor Red - Write-Host "URL: $DOWNLOAD_URL" -ForegroundColor Yellow - exit 1 - } + $tmpExe = Join-Path $tmpDir $BINARY_NAME - if (-not (Test-Path $tmpExe)) { - Write-Host "Download failed: $tmpExe not found." -ForegroundColor Red - exit 1 - } + Write-Host "Downloading release asset:" -ForegroundColor Cyan + Write-Host " $DOWNLOAD_URL" -ForegroundColor DarkGray - if ($NETSCLI_SHA256_URL -and $NETSCLI_SHA256_URL.Trim().Length -gt 0) { - Write-Host "Fetching checksum from: $NETSCLI_SHA256_URL" -ForegroundColor Cyan - $checksumResponse = Invoke-WebRequest -Uri $NETSCLI_SHA256_URL - $NETSCLI_SHA256 = ($checksumResponse.Content -split '\s+')[0] - } elseif (-not $NETSCLI_SHA256 -or $NETSCLI_SHA256.Trim().Length -eq 0) { - # Every release since v0.2.x publishes ".sha256". Fetch it — and - # treat its absence as a failure, not as permission to skip - # verification. An attacker on the path can always make one request - # fail; if that silently downgraded us to "install unverified", the - # checksum would be worth nothing. - $autoChecksumUrl = "$DOWNLOAD_URL.sha256" try { - $checksumResponse = Invoke-WebRequest -Uri $autoChecksumUrl - $NETSCLI_SHA256 = ($checksumResponse.Content -split '\s+')[0] + Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $tmpExe } catch { - Write-Host "ERROR: could not fetch $autoChecksumUrl" -ForegroundColor Red - Write-Host "Refusing to install an unverified binary." -ForegroundColor Red - Write-Host "" - Write-Host "If this release genuinely has no checksum asset, supply one explicitly:" -ForegroundColor Yellow - Write-Host ' $env:NETSCLI_SHA256="" # known digest' -ForegroundColor Yellow - Write-Host ' $env:NETSCLI_SHA256_URL="" # digest fetched from elsewhere' -ForegroundColor Yellow - Write-Host ' $env:NETSCLI_ALLOW_UNVERIFIED=1 # opt out (not recommended)' -ForegroundColor Yellow - exit 1 + Write-Host "Download failed." -ForegroundColor Red + Write-Host $_.Exception.Message -ForegroundColor Red + Write-Host "URL: $DOWNLOAD_URL" -ForegroundColor Yellow + throw "NetsCLI was not installed: the download failed." } - } - if ($NETSCLI_SHA256 -and $NETSCLI_SHA256.Trim().Length -gt 0) { - $expected = $NETSCLI_SHA256.Trim().ToLower() - if ($expected -notmatch '^[0-9a-f]{64}$') { - Write-Host "ERROR: checksum is not a 64-character hex digest: '$expected'" -ForegroundColor Red - exit 1 - } - Write-Host "Verifying checksum..." -ForegroundColor Cyan - $hash = (Get-FileHash -Algorithm SHA256 -Path $tmpExe).Hash.ToLower() - if ($hash -ne $expected) { - Write-Host "Checksum mismatch. Expected $expected, got $hash" -ForegroundColor Red - exit 1 + if (-not (Test-Path $tmpExe)) { + Write-Host "Download failed: $tmpExe not found." -ForegroundColor Red + throw "NetsCLI was not installed: the download failed." } - } elseif (Test-True $NETSCLI_ALLOW_UNVERIFIED) { - Write-Host "Warning: installing WITHOUT checksum verification (NETSCLI_ALLOW_UNVERIFIED=1)." -ForegroundColor Yellow - } else { - Write-Host "ERROR: no checksum available and NETSCLI_ALLOW_UNVERIFIED is not set." -ForegroundColor Red - exit 1 - } - if (-not (Test-Path $INSTALL_DIR)) { - New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null - } + if ($NETSCLI_SHA256_URL -and $NETSCLI_SHA256_URL.Trim().Length -gt 0) { + Write-Host "Fetching checksum from: $NETSCLI_SHA256_URL" -ForegroundColor Cyan + $checksumResponse = Invoke-WebRequest -Uri $NETSCLI_SHA256_URL + $NETSCLI_SHA256 = ($checksumResponse.Content -split '\s+')[0] + } elseif (-not $NETSCLI_SHA256 -or $NETSCLI_SHA256.Trim().Length -eq 0) { + # Every release since v0.2.x publishes ".sha256". Fetch it — and + # treat its absence as a failure, not as permission to skip + # verification. An attacker on the path can always make one request + # fail; if that silently downgraded us to "install unverified", the + # checksum would be worth nothing. + $autoChecksumUrl = "$DOWNLOAD_URL.sha256" + try { + $checksumResponse = Invoke-WebRequest -Uri $autoChecksumUrl + $NETSCLI_SHA256 = ($checksumResponse.Content -split '\s+')[0] + } catch { + Write-Host "ERROR: could not fetch $autoChecksumUrl" -ForegroundColor Red + Write-Host "Refusing to install an unverified binary." -ForegroundColor Red + Write-Host "" + Write-Host "If this release genuinely has no checksum asset, supply one explicitly:" -ForegroundColor Yellow + Write-Host ' $env:NETSCLI_SHA256="" # known digest' -ForegroundColor Yellow + Write-Host ' $env:NETSCLI_SHA256_URL="" # digest fetched from elsewhere' -ForegroundColor Yellow + Write-Host ' $env:NETSCLI_ALLOW_UNVERIFIED=1 # opt out (not recommended)' -ForegroundColor Yellow + throw "NetsCLI was not installed: there was no checksum to verify it against." + } + } - Copy-Item $tmpExe (Join-Path $INSTALL_DIR $BINARY_NAME) -Force + if ($NETSCLI_SHA256 -and $NETSCLI_SHA256.Trim().Length -gt 0) { + $expected = $NETSCLI_SHA256.Trim().ToLower() + if ($expected -notmatch '^[0-9a-f]{64}$') { + Write-Host "ERROR: checksum is not a 64-character hex digest: '$expected'" -ForegroundColor Red + throw "NetsCLI was not installed: the checksum is malformed." + } + Write-Host "Verifying checksum..." -ForegroundColor Cyan + $hash = (Get-FileHash -Algorithm SHA256 -Path $tmpExe).Hash.ToLower() + if ($hash -ne $expected) { + Write-Host "Checksum mismatch. Expected $expected, got $hash" -ForegroundColor Red + throw "NetsCLI was not installed: its checksum did not match." + } + } elseif (Test-True $NETSCLI_ALLOW_UNVERIFIED) { + Write-Host "Warning: installing WITHOUT checksum verification (NETSCLI_ALLOW_UNVERIFIED=1)." -ForegroundColor Yellow + } else { + Write-Host "ERROR: no checksum available and NETSCLI_ALLOW_UNVERIFIED is not set." -ForegroundColor Red + throw "NetsCLI was not installed: there was no checksum to verify it against." + } - Write-Host "" - Write-Host "Installed successfully." -ForegroundColor Green - Write-Host "Location: $INSTALL_DIR\$BINARY_NAME" -ForegroundColor Green + if (-not (Test-Path $INSTALL_DIR)) { + New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null + } - $pathParts = $env:PATH -split ';' - $inPath = $false - foreach ($p in $pathParts) { - if ($p.TrimEnd('\\') -ieq $INSTALL_DIR.TrimEnd('\\')) { $inPath = $true } - } + Copy-Item $tmpExe (Join-Path $INSTALL_DIR $BINARY_NAME) -Force - if (-not $inPath) { Write-Host "" - Write-Host "NOTE: $INSTALL_DIR is not in your PATH." -ForegroundColor Yellow - Write-Host "Add it via Windows Settings -> Environment Variables, or run:" -ForegroundColor Yellow - Write-Host " setx PATH `"$env:PATH;$INSTALL_DIR`"" -ForegroundColor Yellow - } + Write-Host "Installed successfully." -ForegroundColor Green + Write-Host "Location: $INSTALL_DIR\$BINARY_NAME" -ForegroundColor Green - if ($ENABLE_PCAP) { - if (Test-NpcapInstalled) { - Write-Host "" - Write-Host "Npcap detected — no install needed." -ForegroundColor Green - } elseif ($SKIP_NPCAP) { - Write-Host "" - Write-Host "Npcap not detected; skipping install as requested (NETSCLI_SKIP_NPCAP=1)." -ForegroundColor Yellow - } else { + $pathParts = $env:PATH -split ';' + $inPath = $false + foreach ($p in $pathParts) { + if ($p.TrimEnd('\\') -ieq $INSTALL_DIR.TrimEnd('\\')) { $inPath = $true } + } + + if (-not $inPath) { Write-Host "" - Write-Host "Npcap not detected. Installing..." -ForegroundColor Yellow - $ok = Install-Npcap -downloadDir $tmpDir -url $NETSCLI_NPCAP_URL - if ($ok) { - Write-Host "Npcap installation completed." -ForegroundColor Green + Write-Host "NOTE: $INSTALL_DIR is not in your PATH." -ForegroundColor Yellow + Write-Host "Add it via Windows Settings -> Environment Variables, or run:" -ForegroundColor Yellow + Write-Host " setx PATH `"$env:PATH;$INSTALL_DIR`"" -ForegroundColor Yellow + } + + if ($ENABLE_PCAP) { + if (Test-NpcapInstalled) { + Write-Host "" + Write-Host "Npcap detected — no install needed." -ForegroundColor Green + } elseif ($SKIP_NPCAP) { + Write-Host "" + Write-Host "Npcap not detected; skipping install as requested (NETSCLI_SKIP_NPCAP=1)." -ForegroundColor Yellow } else { - Write-Host "Npcap installation failed or was cancelled." -ForegroundColor Red - Write-Host "Install manually from https://npcap.com/#download (pick WinPcap-compatible mode)." -ForegroundColor Yellow + Write-Host "" + Write-Host "Npcap not detected. Installing..." -ForegroundColor Yellow + $ok = Install-Npcap -downloadDir $tmpDir -url $NETSCLI_NPCAP_URL + if ($ok) { + Write-Host "Npcap installation completed." -ForegroundColor Green + } else { + Write-Host "Npcap installation failed or was cancelled." -ForegroundColor Red + Write-Host "Install manually from https://npcap.com/#download (pick WinPcap-compatible mode)." -ForegroundColor Yellow + } } } - } - Write-Host "" - Write-Host "Try: netscli --help" -ForegroundColor Green - if (-not $ENABLE_PCAP) { Write-Host "" - Write-Host "PCAP support is not installed in this build." -ForegroundColor DarkGray - Write-Host "To add packet capture, re-run with NETSCLI_PCAP=1 (admin required for Npcap)." -ForegroundColor DarkGray - } - Write-Host "- Ensure wpcap.dll is on PATH (for example C:\\Windows\\System32\\Npcap)." -ForegroundColor DarkGray + Write-Host "Try: netscli --help" -ForegroundColor Green + if (-not $ENABLE_PCAP) { + Write-Host "" + Write-Host "PCAP support is not installed in this build." -ForegroundColor DarkGray + Write-Host "To add packet capture, re-run with NETSCLI_PCAP=1 (admin required for Npcap)." -ForegroundColor DarkGray + } + Write-Host "- Ensure wpcap.dll is on PATH (for example C:\\Windows\\System32\\Npcap)." -ForegroundColor DarkGray -} finally { - Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue | Out-Null + } finally { + Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue | Out-Null + } } + +Install-NetsCLI From 3c0e098f4c65b7f17e0e8829a39bb82142b14233 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:27:41 +0100 Subject: [PATCH 16/23] install.ps1: require netscli.exe's Authenticode signature The installer checked only a SHA-256 fetched from the same release as the binary, which proves the download is intact but not who built it, although every netscli.exe since v0.3.3 is Authenticode-signed. It already checked the Npcap installer's signature, but not its own binary. After the checksum it now requires Get-AuthenticodeSignature to report Valid and the signer CN to be "Open Source Developer Felix Stubner" (the subject the v0.3.4 release log shows for the project's Certum certificate). A bad signature stops the install. It says so, and checks nothing, for an explicitly pinned NETSCLI_VERSION older than v0.3.3, for a REPO other than fstubner/netscli unless NETSCLI_SIGNER names its signer, and where Get-AuthenticodeSignature is missing. NETSCLI_SIGNER and NETSCLI_SKIP_SIGNATURE=1 are the overrides. Before installing it prints what it checked. Not run locally: pwsh is not available in this environment, so CI's PSScriptAnalyzer step is the first check of this file. Audit M20 (ci-release M-4). --- scripts/install.ps1 | 92 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/scripts/install.ps1 b/scripts/install.ps1 index e47fce85..05dd0b44 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -20,6 +20,23 @@ # the release and REFUSES to install if it cannot be verified. # - NETSCLI_ALLOW_UNVERIFIED=1: install without checksum verification # (not recommended; only for releases that publish no checksum asset) +# - NETSCLI_SIGNER: the Authenticode signer netscli.exe must carry +# (default: "Open Source Developer Felix Stubner", the CN of the +# project's Certum certificate) +# - NETSCLI_SKIP_SIGNATURE=1: do not check the Authenticode signature +# +# What is verified before anything is installed: +# - The SHA-256 of netscli.exe, against ".sha256" from the same +# release (or NETSCLI_SHA256 / NETSCLI_SHA256_URL). That proves the +# download is intact, not who built it, since both come from one place. +# - Its Authenticode signature: Windows must report it Valid (signed, +# unchanged since, chaining to a root this machine trusts) and the signer +# must be NETSCLI_SIGNER. A bad signature stops the install. Skipped, +# with a note, for a pinned NETSCLI_VERSION older than v0.3.3 (the first +# signed release), for a REPO other than fstubner/netscli unless +# NETSCLI_SIGNER is set, and where Get-AuthenticodeSignature does not +# exist. +# The script says what it checked. # Ensure TLS 1.2 for older Windows PowerShell try { @@ -129,6 +146,47 @@ function Install-Npcap([string]$downloadDir, [string]$url) { return $proc.ExitCode -eq 0 } +# netscli.exe is Authenticode-signed from v0.3.3 on. A pinned version older +# than that is the one release that may lack a signature; "latest" and +# anything newer must have one. A version that does not parse is treated as +# signed, so the check is never skipped by accident. +function Test-SignedRelease([string]$version) { + if (-not $version -or $version.Trim().Length -eq 0) { return $true } + if ($version.Trim() -match '^v?(\d+)\.(\d+)\.(\d+)') { + $pinned = [version]::new([int]$Matches[1], [int]$Matches[2], [int]$Matches[3]) + return $pinned -ge [version]::new(0, 3, 3) + } + return $true +} + +# The same check Test-NpcapSignature makes, for netscli.exe itself. Valid +# means signed, unmodified since, and chaining to a root this machine +# trusts, which is the judgement SmartScreen makes too. The signer is +# compared by CN alone, as for Npcap, so a reissued certificate with the +# same name keeps passing. +function Test-NetscliSignature([string]$path, [string]$expectedSigner) { + $sig = Get-AuthenticodeSignature -FilePath $path + if ($sig.Status -ne "Valid") { + Write-Host "netscli.exe's signature is not valid (status: $($sig.Status))." -ForegroundColor Red + if ($sig.StatusMessage) { + Write-Host " $($sig.StatusMessage)" -ForegroundColor Red + } + return $false + } + if (-not $sig.SignerCertificate) { + Write-Host "netscli.exe carries no signer certificate." -ForegroundColor Red + return $false + } + $signer = $sig.SignerCertificate.GetNameInfo( + [System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) + if ($signer -ne $expectedSigner) { + Write-Host "netscli.exe is signed by '$signer', not by '$expectedSigner'." -ForegroundColor Red + return $false + } + Write-Host "netscli.exe signed by: $signer" -ForegroundColor Green + return $true +} + # Everything the installer does. In a function so that its variables and # its $ErrorActionPreference stay inside it: run as `iwr ... | iex`, the # script runs in the caller's own PowerShell session, and top-level @@ -150,6 +208,8 @@ function Install-NetsCLI { $NETSCLI_PCAP = if ($env:NETSCLI_PCAP) { $env:NETSCLI_PCAP } else { "" } $NETSCLI_SKIP_NPCAP = if ($env:NETSCLI_SKIP_NPCAP) { $env:NETSCLI_SKIP_NPCAP } else { "" } $NETSCLI_ALLOW_UNVERIFIED = if ($env:NETSCLI_ALLOW_UNVERIFIED) { $env:NETSCLI_ALLOW_UNVERIFIED } else { "" } + $NETSCLI_SIGNER = if ($env:NETSCLI_SIGNER) { $env:NETSCLI_SIGNER } else { "Open Source Developer Felix Stubner" } + $NETSCLI_SKIP_SIGNATURE = if ($env:NETSCLI_SKIP_SIGNATURE) { $env:NETSCLI_SKIP_SIGNATURE } else { "" } # Backwards-compat: older docs used NETSCLI_INSTALL_NPCAP as a separate toggle. # If the user set it, fold it into NETSCLI_PCAP so the old invocation works. @@ -207,11 +267,16 @@ function Install-NetsCLI { throw "NetsCLI was not installed: the download failed." } + # What was checked, for the line printed before installing. + $checked = @() + $sumSource = "the SHA-256 given in NETSCLI_SHA256" if ($NETSCLI_SHA256_URL -and $NETSCLI_SHA256_URL.Trim().Length -gt 0) { Write-Host "Fetching checksum from: $NETSCLI_SHA256_URL" -ForegroundColor Cyan $checksumResponse = Invoke-WebRequest -Uri $NETSCLI_SHA256_URL $NETSCLI_SHA256 = ($checksumResponse.Content -split '\s+')[0] + $sumSource = "the SHA-256 from NETSCLI_SHA256_URL" } elseif (-not $NETSCLI_SHA256 -or $NETSCLI_SHA256.Trim().Length -eq 0) { + $sumSource = "the SHA-256 published with this release" # Every release since v0.2.x publishes ".sha256". Fetch it — and # treat its absence as a failure, not as permission to skip # verification. An attacker on the path can always make one request @@ -245,6 +310,7 @@ function Install-NetsCLI { Write-Host "Checksum mismatch. Expected $expected, got $hash" -ForegroundColor Red throw "NetsCLI was not installed: its checksum did not match." } + $checked += $sumSource } elseif (Test-True $NETSCLI_ALLOW_UNVERIFIED) { Write-Host "Warning: installing WITHOUT checksum verification (NETSCLI_ALLOW_UNVERIFIED=1)." -ForegroundColor Yellow } else { @@ -252,6 +318,32 @@ function Install-NetsCLI { throw "NetsCLI was not installed: there was no checksum to verify it against." } + # The checksum above comes from the same release as the binary, so it + # proves the download is intact but not who built it. The Authenticode + # signature does, and Windows checks it locally. A signature that does + # not check out stops the install; the cases where there is nothing to + # check, or no way to check it, are said out loud instead. + if (Test-True $NETSCLI_SKIP_SIGNATURE) { + Write-Host "Not checked: the Authenticode signature (NETSCLI_SKIP_SIGNATURE=1)." -ForegroundColor Yellow + } elseif ($REPO -ne "fstubner/netscli" -and -not $env:NETSCLI_SIGNER) { + Write-Host "Not checked: the Authenticode signature. REPO is $REPO, so set NETSCLI_SIGNER to its signer to check it." -ForegroundColor Yellow + } elseif (-not (Test-SignedRelease $NETSCLI_VERSION)) { + Write-Host "Not checked: the Authenticode signature. $NETSCLI_VERSION is older than v0.3.3, the first signed release." -ForegroundColor Yellow + } elseif (-not (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue)) { + Write-Host "Not checked: the Authenticode signature. Get-AuthenticodeSignature is not available in this PowerShell." -ForegroundColor Yellow + } else { + Write-Host "Verifying the Authenticode signature..." -ForegroundColor Cyan + if (-not (Test-NetscliSignature $tmpExe $NETSCLI_SIGNER)) { + Write-Host "Refusing to install a binary whose signature does not check out." -ForegroundColor Red + Write-Host "If you know why, NETSCLI_SKIP_SIGNATURE=1 skips this check." -ForegroundColor Yellow + throw "NetsCLI was not installed: its signature did not check out." + } + $checked += "the Authenticode signature by $NETSCLI_SIGNER" + } + if ($checked.Count -gt 0) { + Write-Host "Checked: $($checked -join ', and ')." -ForegroundColor Green + } + if (-not (Test-Path $INSTALL_DIR)) { New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null } From 2e5ab9b40f22221a522826790ac0e69793e595e4 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:28:21 +0100 Subject: [PATCH 17/23] msstore-submit: keep the client secret and token off curl's command line The Entra client secret was a --data-urlencode argument and the bearer token an -H argument, so both sat in curl's argv, readable by any process on the runner. The secret now reaches curl on stdin (client_secret@-), and the token is written to a header file in a private temporary directory and passed as -H @file. The tag is checked with lib.sh's validate_tag before it reaches the package URL. msstore-submit_test.sh needs jq, which this machine lacks, so it was not run here. It runs in CI's script-lint job. Audit ci-release L-14. --- scripts/release/msstore-submit.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/scripts/release/msstore-submit.sh b/scripts/release/msstore-submit.sh index ca39d585..d109f553 100755 --- a/scripts/release/msstore-submit.sh +++ b/scripts/release/msstore-submit.sh @@ -24,10 +24,21 @@ # MSSTORE_SELLER_ID MSSTORE_PRODUCT_ID set -euo pipefail +# shellcheck source=scripts/release/lib.sh +. "$(dirname "$0")/lib.sh" + tag="${1:?usage: msstore-submit.sh }" +# The tag goes into the package URL the Store is told to fetch. +validate_tag "$tag" : "${MSSTORE_TENANT_ID:?}" "${MSSTORE_CLIENT_ID:?}" "${MSSTORE_CLIENT_SECRET:?}" : "${MSSTORE_SELLER_ID:?}" "${MSSTORE_PRODUCT_ID:?}" +# The client secret and the access token never go on a curl command line, +# where any process on the runner can read them: the secret goes to curl on +# stdin, and the token in a header file only this user can read. +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + api="https://api.store.microsoft.com/submission/v1/product/${MSSTORE_PRODUCT_ID}" package_url="https://netscli.com/download/${tag}/netscli-gui-windows-x86_64.msi" ready_attempts="${MSSTORE_READY_ATTEMPTS:-60}" @@ -38,22 +49,25 @@ if [ "${status}" != "200" ]; then exit 1 fi -token=$(curl -sf "https://login.microsoftonline.com/${MSSTORE_TENANT_ID}/oauth2/v2.0/token" \ +# `client_secret@-` reads the value from stdin; printf is a shell builtin, +# so the secret is not on its command line either. +token=$(printf '%s' "${MSSTORE_CLIENT_SECRET}" | curl -sf "https://login.microsoftonline.com/${MSSTORE_TENANT_ID}/oauth2/v2.0/token" \ --data-urlencode "grant_type=client_credentials" \ --data-urlencode "client_id=${MSSTORE_CLIENT_ID}" \ - --data-urlencode "client_secret=${MSSTORE_CLIENT_SECRET}" \ + --data-urlencode "client_secret@-" \ --data-urlencode "scope=https://api.store.microsoft.com/.default" | jq -r '.access_token') if [ -z "${token}" ] || [ "${token}" = "null" ]; then echo "No token from Entra. Check the MSSTORE_* secrets, and whether the client secret has expired." >&2 exit 1 fi +(umask 077 && printf 'Authorization: Bearer %s\n' "${token}" >"${work}/auth-header") # Every API answer carries isSuccess and errors. Print the errors and stop on # a failure, otherwise print the body for the caller. call() { local method="$1" path="$2" response local args=(-s -X "${method}" "${api}${path}" - -H "Authorization: Bearer ${token}" + -H "@${work}/auth-header" -H "X-Seller-Account-Id: ${MSSTORE_SELLER_ID}" -H "Content-Type: application/json") if [ -n "${3:-}" ]; then args+=(--data "$3"); fi From 93697e095bce33176c28dee16515af668612198f Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:30:07 +0100 Subject: [PATCH 18/23] ci: timeouts on the site and audit jobs, cargo-audit and wrangler pinned - audit.yml: cargo-audit's cache key was the constant `cargo-audit-v1`, which the weekly run kept warm, so it was never upgraded. It is now keyed on CARGO_AUDIT_VERSION (0.22.2), which the install also pins. - timeout-minutes on cargo audit, every site.yml job and the preview job, which ran with GitHub's 360-minute default. Sized from measured runs: Site's build took 3.5 min (run 37385259611), the preview 43 s (run 37398996660). - site-preview.yml: `npx wrangler@4` installed the newest 4.x on every run and handed it CLOUDFLARE_API_TOKEN. Pinned to 4.148.0. Its header and PR comment still said pages.yml is manual-only; it deploys on pushes to main. Audit ci-release L-6, L-7, repo-docs 20(a). --- .github/workflows/audit.yml | 16 ++++++++++++++-- .github/workflows/site-preview.yml | 16 +++++++++++----- .github/workflows/site.yml | 4 ++++ 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index b496bbe7..af348f8f 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -42,10 +42,17 @@ concurrency: permissions: contents: read +env: + # The latest release on crates.io when this was set (2026-10-07). + CARGO_AUDIT_VERSION: 0.22.2 + jobs: audit: name: cargo audit runs-on: ubuntu-latest + # A cold run compiles cargo-audit, a few minutes; GitHub's default + # would let a stalled one run for six hours. + timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -63,16 +70,21 @@ jobs: shared-key: audit # Cache cargo-audit itself so we don't reinstall every run. + # + # Keyed on a version pinned here. The key used to be a constant, + # `cargo-audit-v1`, and the weekly run keeps a cache warm, so the + # binary installed once was never upgraded. Bump CARGO_AUDIT_VERSION + # to upgrade; the new key misses and reinstalls. - name: Cache cargo-audit id: cache-cargo-audit uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cargo/bin/cargo-audit - key: cargo-audit-v1 + key: cargo-audit-${{ env.CARGO_AUDIT_VERSION }} - name: Install cargo-audit if: steps.cache-cargo-audit.outputs.cache-hit != 'true' - run: cargo install cargo-audit --locked + run: cargo install cargo-audit --version "$CARGO_AUDIT_VERSION" --locked - name: Run cargo audit run: cargo audit diff --git a/.github/workflows/site-preview.yml b/.github/workflows/site-preview.yml index c34af8b9..ebf7cc93 100644 --- a/.github/workflows/site-preview.yml +++ b/.github/workflows/site-preview.yml @@ -3,9 +3,9 @@ name: Site Preview # Per-PR preview deploys of site/ to Cloudflare Pages. # # PRODUCTION IS NOT TOUCHED BY THIS WORKFLOW. netscli.com is served from -# GitHub Pages via pages.yml, which stays manual-only. This deploys to a -# separate Cloudflare Pages project and always passes an explicit -# `--branch=pr-`, which Cloudflare treats as a *preview* deployment. +# GitHub Pages via pages.yml. This deploys to a separate Cloudflare Pages +# project, always with an explicit `--branch` (`pr-` for a pull request, +# `main` for a push), which that project treats as a *preview* deployment. # There is no code path here that produces a production deployment. # # A missing secret fails this workflow rather than skipping it. @@ -59,6 +59,7 @@ jobs: preview: name: Deploy preview runs-on: ubuntu-latest + timeout-minutes: 15 # Secrets are not exposed to pull_request runs from forks, so a fork PR # would otherwise fail on an empty token. Skip rather than fail; the # maintainer can deploy a preview from a local branch if needed. @@ -151,7 +152,12 @@ jobs: # If a production branch is ever set on it, and it is set to main, # this line starts producing production deployments of the preview # project. Point it at a name that is not the production branch. - npx --yes wrangler@4 pages deploy dist \ + # + # An exact wrangler version, not `wrangler@4`, which installed the + # newest 4.x on every run and handed it CLOUDFLARE_API_TOKEN. + # Dependabot does not see this line, so bump it by hand: + # `npm view wrangler@4 version` lists the candidates. + npx --yes wrangler@4.148.0 pages deploy dist \ --project-name=netscli-site-preview \ --branch="${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || 'main' }}" \ --commit-dirty=true \ @@ -172,7 +178,7 @@ jobs: const body = `${marker}\n**Site preview:** ${url}\n\n` + `Built from ${context.sha.slice(0, 7)} with \`NETSCLI_PREVIEW=1\` — ` + `noindex, and analytics disabled so it does not report into netscli.com's numbers.\n\n` + - `Production is unaffected: netscli.com is served from GitHub Pages via \`pages.yml\`, which is manual-only.`; + `Production is unaffected: netscli.com is served from GitHub Pages via \`pages.yml\`, which deploys from main.`; const { data: comments } = await github.rest.issues.listComments({ ...context.repo, issue_number: context.issue.number, }); diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 5959dfbd..1b9badb0 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -38,6 +38,7 @@ jobs: changes: name: Detect site changes runs-on: ubuntu-latest + timeout-minutes: 5 outputs: site: ${{ steps.filter.outputs.site }} steps: @@ -76,6 +77,7 @@ jobs: needs: changes if: needs.changes.outputs.site == 'true' runs-on: ubuntu-latest + timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -125,6 +127,7 @@ jobs: needs: [changes, maintainability] if: needs.changes.outputs.site == 'true' runs-on: ubuntu-latest + timeout-minutes: 30 defaults: run: working-directory: site @@ -208,6 +211,7 @@ jobs: - maintainability - build runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Verify no upstream job failed shell: bash From fba39c19c5046b9a95239b44531e1faa45d2e860 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:32:24 +0100 Subject: [PATCH 19/23] ci: drop unmeasured durations from workflow comments Four comments gave build and job durations nobody measured ("40 minutes", "a quarter of an hour", "in a minute", "a few minutes"). They now say what happens without a number. --- .github/workflows/audit.yml | 4 ++-- .github/workflows/publish-preflight.yml | 6 +++--- .github/workflows/publish-release.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index af348f8f..6ade8178 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -50,8 +50,8 @@ jobs: audit: name: cargo audit runs-on: ubuntu-latest - # A cold run compiles cargo-audit, a few minutes; GitHub's default - # would let a stalled one run for six hours. + # GitHub's default would let a stalled run go on for six hours. 30 + # leaves room for a cold cache, which compiles cargo-audit first. timeout-minutes: 30 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/publish-preflight.yml b/.github/workflows/publish-preflight.yml index 4f287fc3..cadd35cc 100644 --- a/.github/workflows/publish-preflight.yml +++ b/.github/workflows/publish-preflight.yml @@ -10,9 +10,9 @@ name: Publish preflight # most likely way to reach that state: the secrets here were last set on # 2026-05-03 and GitHub PATs commonly carry a 90-day expiry. # -# The signing credentials fail earlier and more expensively: a wrong updater -# key or password is found by the bundler, a quarter of an hour into the -# release build. The `signing` job finds it in a minute. +# The signing credentials fail later and more expensively. A wrong updater +# key or password is found by the bundler, partway through the release +# build. The `signing` job finds it before a release is started. # # Every request below is a read. Nothing is created, forked, pushed or # tagged. Run it before publishing a release, from main: the `signing` job diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4464ae7f..0cc652f4 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -78,7 +78,7 @@ concurrency: cancel-in-progress: false jobs: - # Everything that can be wrong before spending 40 minutes on a build. + # Everything that can be wrong before the build starts. prepare: name: Check the tag and the draft runs-on: ubuntu-latest @@ -121,7 +121,7 @@ jobs: # The signing jobs use the release-signing environment, which only # admits deployments from main and v* tags. From any other branch - # the build would run for a quarter of an hour and then be refused. + # the build would run and then be refused at its first signing job. if [[ "$GITHUB_REF" != "refs/heads/${DEFAULT_BRANCH}" ]]; then echo "::error::dispatch this from ${DEFAULT_BRANCH}, not ${GITHUB_REF}." exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b613e44b..dee1b024 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,8 +55,8 @@ jobs: # # The default branch, because the `release-signing` environment the # signing jobs use allows deployments from `main` and `v*` tags only. A run - # from anywhere else would build for 15 minutes and then be refused at the - # first signing job. + # from anywhere else would build and then be refused at its first signing + # job. guard: name: Check the release is a draft runs-on: ubuntu-latest From 50830238174a2eaf1195b43a65635902be0d83f9 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:39:02 +0100 Subject: [PATCH 20/23] docs: describe the release pipeline as it now runs RELEASE.md and PUBLISHING.md were one pipeline generation behind (audit M32-M37) and now describe this branch's pipeline. RELEASE.md, the process: - The flow is bump, tag and push, date the changelog and write the release summary, preflight, then publish-release.yml, which builds into the draft, checks the assets and only then publishes. It no longer tells you to create a second draft or hand-edit notes the workflow overwrites, and it says to push the tag (M33). - "When something fails" gives the real re-run paths, including `-f only=` with every job id (M32), the one-day limit on the unsigned Windows files, and the lock's queueing caveat (L-3). - The release-signing environment section asks for the main + v* policy instead of forbidding one. The winget token section explains the workflow scope and the bot-account move (M35). - A staged in-app update check, against a local server and a throwaway key, as a release step (M16). - Windows signing, the Store and the updater are described as shipped, not deferred (M36). The cosign identity is the exact one (L-11). The NSIS smoke, the yay line and the "tag refs" claim are gone. PUBLISHING.md, the per-channel reference: - All channels and all 13 publish jobs, the 73-file asset set, npm, the MCP Registry, MCP bundles and netscli.com (M34). - crates.io is the automated job, with the hand-run command as a fallback after publication. The manual three-step flow and `sleep 90` are gone (M37). - winget via the pinned Komac, Homebrew regenerated from its template, site previews as they behave, the dating rule as the CHANGELOG preamble now states it, and a bump recipe that covers package-lock.json. publish.yml's mcpb-sign comment names both identities it can sign as. --- .github/workflows/publish.yml | 2 + docs/PUBLISHING.md | 583 ++++++++++++++++++---------------- docs/RELEASE.md | 577 ++++++++++++++++++++------------- 3 files changed, 659 insertions(+), 503 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ad999411..520191a9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -845,6 +845,8 @@ jobs: # Sigstore identity is this file rather than release.yml, because the # bundles are built after the release is public: # https://github.com/fstubner/netscli/.github/workflows/publish.yml@refs/heads/main + # or @refs/tags/vX.Y.Z when a release promoted by hand starts this + # workflow from its tag. docs/RELEASE.md gives the verify command. mcpb-sign: name: MCP bundles (sign and attach) needs: mcpb diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index f07377f4..6d72b481 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -1,7 +1,7 @@ # Publishing netscli -Per-channel reference: what each distribution channel is, what publishes to -it, what it needs, and how to fix it when it breaks. +The per-channel reference. It says what each distribution channel is, what +publishes to it, what it needs, and how to fix it when it breaks. For the step-by-step *process* of cutting a release, see [`RELEASE.md`](RELEASE.md). This document is the reference you consult when @@ -9,136 +9,103 @@ one channel misbehaves. ## Channels at a glance -netscli publishes to **six** channels via **nine** automated jobs. Everything -except the crates.io publish is triggered by promoting a GitHub release to -public. +`publish-release.yml` builds the GitHub release with `release.yml` while it is +still a draft, checks it, makes it public, and then starts `publish.yml`, +which publishes to every other channel in 13 jobs, and `pages.yml`, which +redeploys netscli.com. -| Channel | Artifacts | Job in `publish.yml` | Deployed to | +| Channel | Artifacts | Built or published by | Deployed to | | --- | --- | --- | --- | +| GitHub Releases | 11 CLI binaries and 7 desktop files, each with `.sha256`, `.sig` and `.pem`, plus `latest.json` | `release.yml`, called by `publish-release.yml` | this repo's Releases | | crates.io | `netscli-core`, `netscli-mcp`, `netscli` | `crates-io` | crates.io | -| GitHub Releases | 11 CLI assets + 5 GUI installers, each with `.sha256`/`.sig`/`.pem` | *(`release.yml`, not `publish.yml`)* | this repo's Releases | -| Homebrew | CLI formula + GUI cask | `homebrew`, `homebrew-cask` | `fstubner/homebrew-tap` | -| Scoop | CLI + GUI manifests | `scoop`, `scoop-gui` | `fstubner/scoop-bucket` | +| npm | the `netscli` launcher and 5 platform packages | `npm` | npmjs.com | +| MCP Registry | `io.github.fstubner/netscli`, which points at the npm package | `mcp-registry` | registry.modelcontextprotocol.io | +| MCP bundles | 5 `.mcpb` files, each with `.sha256`, `.sig` and `.pem` | `mcpb`, `mcpb-sign` | this repo's Releases | +| Homebrew | CLI formula and GUI cask | `homebrew`, `homebrew-cask` | `fstubner/homebrew-tap` | +| Scoop | CLI and GUI manifests | `scoop`, `scoop-gui` | `fstubner/scoop-bucket` | | winget | `fstubner.netscli`, `fstubner.netscli.gui` | `winget`, `winget-gui` | `microsoft/winget-pkgs` (PR) | -| AUR | `netscli-bin`, `netscli-gui-bin` | `aur`, `aur-gui` | `aur.archlinux.org` | +| AUR | `netscli-bin`, `netscli-gui-bin` | `aur`, `aur-gui` | aur.archlinux.org | +| netscli.com | the site, `/install.sh`, `/install.ps1`, the Store's MSI copies | `pages.yml` | GitHub Pages | +| Microsoft Store | the desktop MSI | `msstore.yml`, run by hand | Partner Center, see [`MICROSOFT-STORE.md`](MICROSOFT-STORE.md) | + +The jobs without a workflow name in the third column are in `publish.yml`. Templates for the packaging manifests live under [`packaging/`](../packaging/) for reference. The **deployed** copies live in the tap, bucket, AUR, and -winget-pkgs — the jobs re-stamp those on every release; the templates in this -repo are not what users install. +winget-pkgs. The jobs regenerate those on every release, and the templates in +this repo are not what users install. ### What is NOT published - **The desktop app is not on crates.io.** `netscli-gui` is a Tauri app, distributed only as platform installers. -- **No published artifact has packet capture.** Every release asset and - installer is built without `--features pcap`, deliberately, so the default - install has no libpcap/Npcap dependency and we do not redistribute Npcap. - The `-pcap` CLI variants are the exception and are published as separate - release assets. There is no packet-capture desktop installer at all. - -## Crates.io - -Three crates depend on each other, and crates.io requires dependencies to be -published first, so the order matters. - -### Publish order - -1. `netscli-core` (the library — depends on nothing in-workspace) -2. `netscli-mcp` (depends on `netscli-core`) -3. `netscli` (depends on `netscli-core` and `netscli-mcp`) - -The `netscli-gui` crate is a Tauri app, not intended for crates.io — it's -distributed as platform installers via the GitHub release workflow. - -## One-time setup - -```bash -# If you don't already have one, create an account at https://crates.io -# and generate an API token in your Account Settings. -cargo login -``` - -## Pre-publish checklist - -Check the publishing credentials first, because they are the slowest thing -to fix and the only one that fails *during* a release: - -```bash -gh workflow run publish-preflight.yml -``` - -It validates all five secrets (crates.io, the tap, the bucket, winget, AUR) -and publishes nothing — every request is a read. Worth doing because -`publish.yml` pushes to each registry the moment that job succeeds and AUR -has no review step, so a credential that expired quietly between releases -leaves some registries on the new version and some on the old. GitHub PATs -commonly carry a 90-day expiry; releases here are months apart. - -```bash -# Everything green, nothing uncommitted. -cargo fmt --check -cargo clippy --all-targets -- -D warnings -cargo clippy --all-targets --features pcap -- -D warnings -cargo test --all --no-fail-fast -cargo audit -cd apps/netscli-gui && npm run test:unit && npm run test:maintainability && npm run build -git status # should be clean - -# Windows note: `--features pcap` needs the Npcap SDK import library. -# Set LIB to the SDK directory containing wpcap.lib before running the -# PCAP clippy/build gates, for example C:\path\to\npcap-sdk\Lib\x64. - -# Dry-run each crate — this builds a tarball without uploading and -# validates the manifest (no missing metadata, no path-only deps, etc). -cargo package -p netscli-core -cargo package -p netscli-mcp -cargo package -p netscli -``` - -Read the tarball contents (`target/package/*.crate`) if you want to be -sure the right files are being shipped. `cargo package --list -p ` -prints the file list without building. +- **Only the `-pcap` CLI release assets have packet capture.** Every other + release asset, every installer and every package-manager build is built + without `--features pcap`, so the default install has no libpcap or Npcap + dependency and nothing redistributes Npcap. There is no packet-capture + desktop installer at all. + +## crates.io + +Three crates depend on each other, so crates.io has to receive them in order. +`netscli-core` depends on nothing in the workspace, `netscli-mcp` depends on +`netscli-core`, and `netscli` depends on both. + +The `crates-io` job publishes them from the tag, in one command that handles +the order itself. It first asks crates.io which of the three already have the +version and leaves those out, so a re-run after a partial upload finishes the +rest instead of failing on the first. Then: + +1. `cargo publish --dry-run` packages each crate and builds the packaged copy, + with no token in the step. This is the step that runs every dependency's + build scripts. +2. `cargo publish --no-verify` uploads them in dependency order and waits for + each to reach the index. The token is in `CARGO_REGISTRY_TOKEN`, and the + step compiles nothing. + +`ci.yml`'s `publish-dry-run` job runs the same dry run on every push to `main` +that touches code, so a packaging problem usually shows up long before a +release. + +**crates.io publishes are permanent.** You can yank a version but not delete +it, and a yanked version keeps its number. A crate that went up broken means +bumping the patch version, not retrying the same one. -## Publish +Publish by hand only if the job cannot, and only after the release is public: ```bash -# Do these ONE AT A TIME. Each publish blocks until crates.io has indexed -# the new version (usually a few seconds) before the next one can resolve it. - -cargo publish -p netscli-core -# wait a bit, then: -cargo publish -p netscli-mcp -# wait again: -cargo publish -p netscli +cargo publish -p netscli-core -p netscli-mcp -p netscli ``` -If something goes wrong after step 1 or 2, remember: **crates.io publishes -are permanent**. You can't delete a published version, only `yank` it, -and a yanked version still occupies the version number. Bump the patch -(0.1.1, 0.1.2, …) instead of re-publishing the same number. +crates.io also supports trusted publishing from GitHub Actions, which would +remove `CARGO_REGISTRY_TOKEN` altogether. It needs a trusted publisher set up +on each crate's settings page first, and then the job switches to +`rust-lang/crates-io-auth-action`. ## Version bumps -**The crates do NOT inherit a version from the workspace.** Root -`Cargo.toml`'s `[workspace.package]` block has no `version` key — each -crate hardcodes its own, and the desktop app carries three more copies -outside Cargo entirely. Seven files have to move together: +**The crates do NOT inherit a version from the workspace.** Root `Cargo.toml`'s +`[workspace.package]` block has no `version` key. Each crate hardcodes its +own, and the desktop app carries two more copies outside Cargo. Seven files +have to move together: | File | What it sets | | --- | --- | | `crates/netscli-core/Cargo.toml` | core crate version | -| `crates/netscli-mcp/Cargo.toml` | MCP crate version **+ its `netscli-core` dep spec** | -| `apps/netscli-cli/Cargo.toml` | CLI crate version **+ its `netscli-core`/`netscli-mcp` dep specs** | +| `crates/netscli-mcp/Cargo.toml` | MCP crate version **and its `netscli-core` dependency** | +| `apps/netscli-cli/Cargo.toml` | CLI crate version **and its `netscli-core`/`netscli-mcp` dependencies** | | `apps/netscli-gui/src-tauri/Cargo.toml` | Tauri backend crate version | | `apps/netscli-gui/src-tauri/tauri.conf.json` | installer/bundle version | -| `apps/netscli-gui/package.json` | version shown in the GUI About dialog and on the website | +| `apps/netscli-gui/package.json` | version shown in the GUI About dialog | | `CHANGELOG.md` | the release heading (see below for its date and link) | -Six more, under `packaging/`, carry a version that nothing reads — every -publish job rewrites it from the tag before the manifest reaches a -registry. They still have to move, because the version in a template is -what a human reviewing that template believes: +`apps/netscli-gui/package-lock.json` repeats the app's own version, and +`Cargo.lock` the crates', so both change too. + +Six more, under `packaging/`, carry a version that nothing reads. Every +publish job rewrites it from the tag before the manifest reaches a registry. +They still have to move, because the version in a template is what a human +reviewing that template believes: | File | What it sets | | --- | --- | @@ -146,33 +113,35 @@ what a human reviewing that template believes: | `packaging/aur/netscli-gui-bin/PKGBUILD` | `pkgver` | | `packaging/homebrew/netscli.rb` | `version` | | `packaging/homebrew/Casks/netscli-gui.rb` | `version` | -| `packaging/scoop/netscli.json` | `version` **+ the asset URL** | -| `packaging/scoop/netscli-gui.json` | `version` **+ the asset URL** | +| `packaging/scoop/netscli.json` | `version` **and the asset URL** | +| `packaging/scoop/netscli-gui.json` | `version` **and the asset URL** | -All six sat at `0.3.0` — a version that was tagged, never published, and -whose tag was then deleted — until the 0.3.1 release. That is the same -failure the `@@…@@` digest placeholders in those files exist to prevent, -one field over: a plausible-looking value that describes nothing. +All six sat at `0.3.0`, a version that was tagged, never published, and +whose tag was then deleted, until the 0.3.1 release. That is the same failure +the `@@…@@` digest placeholders in those files exist to prevent, one field +over. A plausible-looking value describes nothing. -Missing the last three of the first table is what shipped a GUI installer stamped with the -wrong version at v0.2.4 and got the Winget submission rejected by a -moderator (see the 0.2.4 entry in `CHANGELOG.md`). The website reads its -version from `apps/netscli-gui/package.json`, so a miss there also -silently mislabels netscli.com. +Missing the GUI's three, rows four to six of the first table, is what shipped +a GUI installer stamped with the wrong version at v0.2.4 and got the winget +submission rejected by a moderator (see the 0.2.4 entry in `CHANGELOG.md`). ### The changelog date and link go on after the tag, not with the bump A version heading gets `## [0.4.0]` at bump time and nothing more. The -` — YYYY-MM-DD` and the `[0.4.0]: …/releases/tag/v0.4.0` reference are a -separate commit, made once the tag is pushed — see "Dating the release" -below for the sequence and for why they cannot go in earlier. - -Both are claims about the outside world, and the website reads them straight -out of this file. 0.3.1 was bumped and dated `2026-08-24` in the release -commit, then not tagged; netscli.com showed "v0.3.1 — 24 Aug 2026" for four -days, and the link reference pointed at a tag page that did not exist. The -version bump is a statement about this repository and can happen whenever; -the date and the link are statements about a release that exists. +` - YYYY-MM-DD` and the `[0.4.0]: …/releases/tag/v0.4.0` reference are a +separate commit, made once the tag is pushed. See +[Dating the release](#dating-the-release) for the sequence and for why they +cannot go in earlier. + +0.3.1 was bumped and dated `2026-08-24` in the release commit, then not +tagged. netscli.com showed "v0.3.1, 24 Aug 2026" for four days, and the link +reference pointed at a tag page that did not exist. A date is still not a +release, though. The tag is pushed before the release goes public, so the +date is the day you mean to publish, and it moves if the release slips. +netscli.com does not take a dated heading as published. When `pages.yml` +deploys, it asks GitHub which releases are published, names the newest one +as the current version, and labels any other changelog entry "Not yet +released". To release 0.4.0 from 0.3.0: @@ -192,8 +161,14 @@ sed -i "s/\"version\": \"${OLD}\"/\"version\": \"${NEW}\"/" \ apps/netscli-gui/package.json \ apps/netscli-gui/src-tauri/tauri.conf.json +# package-lock.json records the app's own version twice, at the top and +# under packages[""]. Only its first lines, so a dependency that happens to +# share the version number is not touched. +sed -i "1,10 s/\"version\": \"${OLD}\"/\"version\": \"${NEW}\"/" \ + apps/netscli-gui/package-lock.json + # Packaging templates. The scoop manifests carry the version twice (the -# field and the asset URL), so those take a global replace; the PKGBUILDs +# field and the asset URL), so those take a global replace. The PKGBUILDs # and the Ruby files are anchored, because both carry the old version in a # comment describing a past mistake and those must not move. sed -i "s/${OLD}/${NEW}/g" \ @@ -210,8 +185,8 @@ sed -i "s/^ version \"${OLD}\"/ version \"${NEW}\"/" \ cargo update -w ``` -Then verify every surface agrees before tagging — this should print -`${NEW}` and nothing else: +Then check every surface agrees before tagging. This should print `${NEW}` +and nothing else: ```bash { @@ -219,6 +194,7 @@ Then verify every surface agrees before tagging — this should print apps/netscli-gui/src-tauri/Cargo.toml grep -h '"version"' apps/netscli-gui/package.json \ apps/netscli-gui/src-tauri/tauri.conf.json + head -n 10 apps/netscli-gui/package-lock.json | grep -h '"version"' grep -h '^pkgver=' packaging/aur/PKGBUILD \ packaging/aur/netscli-gui-bin/PKGBUILD grep -h '^ version "' packaging/homebrew/netscli.rb \ @@ -234,106 +210,129 @@ in their explanatory comments, and the check would report a second value on every run until someone stopped believing it. Finally, rename the `## [Unreleased]` heading in `CHANGELOG.md` to -`## [X.Y.Z]` — the version and nothing else. **The date and the link -reference do not go on here.** See the next section for where they go and -why they cannot go here. +`## [X.Y.Z]`, the version and nothing else. **The date and the link +reference do not go on here.** The next section says where they go and why. ## Dating the release -The `— YYYY-MM-DD` and the `[X.Y.Z]: …/releases/tag/vX.Y.Z` reference are a -separate commit, made **after** the tag is pushed. - -That is the opposite of what this file said until 0.3.2, and the old order -could not be followed. `site/scripts/changelog-dates.mjs` asks -`git tag --list` and fails a dated version with no matching tag: +The ` - YYYY-MM-DD` and the `[X.Y.Z]: …/releases/tag/vX.Y.Z` reference are a +separate commit, made **after** the tag is pushed. That is forced. +`site/scripts/changelog-dates.mjs` asks `git tag --list` and fails a dated +version with no matching tag: ``` 0.3.2: CHANGELOG.md dates it 2026-09-21, but there is no v0.3.2 tag. The date goes on with the tag, not with the version bump. ``` -Under branch protection that commit can never go green, because the tag it -is waiting for was supposed to come after it. Nobody hit it before 0.3.2 -because 0.3.1 was dated in a commit that reached `main` without that check -in front of it — and was then not tagged for four days, which is the bug -the check exists to catch. - -So the sequence is: +Under branch protection a commit that dates a version before its tag exists +can never go green, because the tag it is waiting for comes after it. So the +sequence is: -1. Bump the versions and rename the heading to `## [X.Y.Z]` (above). -2. Tag and push (below). This builds nothing on its own. -3. Date the heading, add the link reference, merge that. -4. Write the release notes and publish. +1. Bump the versions and rename the heading to `## [X.Y.Z]` (above), and + merge. +2. Tag the bump commit and push the tag. This builds nothing. +3. Date the heading, add the link reference, add the release's one-line + `releaseSummaries` entry in `site/src/data/site-content/changelog.ts`, + and merge. +4. Run `publish-preflight.yml`, then `publish-release.yml`. -The reasoning is the one in `CHANGELOG.md`'s own header, which this now -matches: the version bump is a statement about this repository and can -happen whenever; the date and the link are statements about a release that -exists. +The release body is generated from the `CHANGELOG.md` section and the +summary when the release goes public, so step 3 is also where the release +notes are written. [`RELEASE.md`](RELEASE.md#the-release-flow) has the whole +flow. ## GitHub Releases -Platform installers, and the download links the install scripts resolve, -come from the GitHub release — not crates.io. `release.yml` is started by -`publish-release.yml`, and still answers `release: published` and a manual -dispatch. +Platform binaries and installers, and the download links the install +scripts resolve, come from the GitHub release, not crates.io. + +`release.yml` builds them, and `publish-release.yml` calls it while the +release is still a draft. It refuses a release that is already public, +because a rebuild would change every digest the package managers have +published. To rebuild a draft's assets by hand: ```bash -git tag vX.Y.Z -git push origin vX.Y.Z -# Pushing the tag builds nothing: release.yml answers `release: published` -# and a manual dispatch, not a tag push. So there is room between here and -# the publish to date the changelog (see above) and read the notes back. - -# Then: Releases → Draft a new release → pick the tag → write the notes. -# release-drafter has usually made the draft already; edit that one rather -# than opening a second. -# Leave it as a draft, and promote it with the workflow — never with the -# web UI's publish button, which races release-drafter (see RELEASE.md). -gh workflow run publish-release.yml -f tag=vX.Y.Z +gh workflow run release.yml -f tag=vX.Y.Z ``` -It builds and attaches, per asset, four files: the artifact, `.sha256`, -`.sig`, and `.pem`. +Each of these is attached with its `.sha256`, `.sig` and `.pem`: -- **11 CLI assets** — linux x86_64/aarch64 (gnu, each with a `-pcap` - variant), linux x86_64 musl (no `-pcap`), windows x86_64 (plus `-pcap`), - macos x86_64/aarch64 (each plus `-pcap`). -- **5 GUI installers** from 4 matrix entries — the Linux entry emits both - `.deb` and `.AppImage`; then two `.dmg` (aarch64, x86_64) and one `.msi`. +- **11 CLI binaries.** Linux x86_64 and aarch64 (gnu, each with a `-pcap` + variant), Linux x86_64 musl (no `-pcap`), Windows x86_64 (plus `-pcap`), + macOS x86_64 and aarch64 (each plus `-pcap`). +- **7 desktop files.** The Linux `.deb` and `.AppImage`, a `.dmg` for each + macOS architecture, the `.app.tar.gz` each macOS app updates from, and the + Windows `.msi`. -Signing is Sigstore keyless via `cosign sign-blob`, using the Actions OIDC -token — no key material is stored. Consumers verify with the command in -[`RELEASE.md`](RELEASE.md#sigstore-signature-verification). Note that -**nothing downstream verifies these signatures automatically**; the package -managers rely on their own hash checks instead. +That is 72 files, and `latest.json` for the in-app updater makes 73. +`publish-release.yml` checks all of them before the release goes public. +The MCP bundles, with their sidecars, add 20 more once it is public. + +Signing: + +- **Sigstore**, keyless, with the Actions OIDC token, so no key material is + stored. Consumers verify with the command in + [`RELEASE.md`](RELEASE.md#verifying-release-signatures). +- **Authenticode** on every Windows executable and installer, since 0.3.3. + See [`RELEASE.md`](RELEASE.md#windows-signing). +- **The updater key** on the four files `latest.json` names, each signature + bound to the release's version, and checked against the app's public key + before `latest.json` is uploaded. + +The package managers check the SHA-256 in their own manifests. +`scripts/install.sh` also checks the Sigstore signature when `cosign` is +installed, and `scripts/install.ps1` the Authenticode signature. **Windows pcap builds** link against the Npcap SDK, which `release.yml` -downloads and verifies against a pinned SHA256. Bumping the SDK version -means re-pinning that digest. +downloads and checks against a pinned SHA-256. Bumping the SDK version means +re-pinning that digest, in `ci.yml` too. + +## netscli.com + +`pages.yml` builds the Astro site in `site/` and deploys it to GitHub Pages. +It runs on pushes to `main` that touch the site or the files it reads, by +hand, and after every release, when `publish-release.yml` starts it. + +- **The current version** is the newest release GitHub lists as published. + The build asks GitHub before it starts, and the changelog page labels + every newer `CHANGELOG.md` entry "Not yet released". If GitHub cannot be + asked, the site names no version and dates no entry rather than guess. +- **`/install.sh` and `/install.ps1`** are `scripts/install.*`, generated + by the build. Before deploying, a job that never ran `npm` checks both are + byte for byte what the commit holds. +- **`/download//netscli-gui-windows-x86_64.msi`** holds the MSI of the + three newest releases that have one, for the Microsoft Store, which + refuses an installer URL that redirects. Each is checked against its + release's `.sha256` and, from v0.3.3 on, its Authenticode signature. + +Only the deploy job holds `pages: write` and `id-token: write`. ## Site previews (Cloudflare Pages) -**Production is not involved.** netscli.com is served from GitHub Pages via -`pages.yml`, which stays manual-only. `site-preview.yml` deploys to a -separate Cloudflare Pages project and always passes an explicit -`--branch=pr-`, which Cloudflare treats as a preview deployment. There is -no code path in that workflow that produces a production deploy. +**Production is not involved.** netscli.com is served from GitHub Pages by +`pages.yml`. `site-preview.yml` deploys to a separate Cloudflare Pages +project, always with an explicit `--branch`. A pull request deploys to +`pr-` and a push to `main` deploys to `main`, and that project treats +both as preview deployments. There is no code path in that workflow that +produces a production deploy. ### Enabling it -The workflow runs today and reports "not configured" until two repository -secrets exist. Nothing fails in the meantime. +Until two repository secrets exist, the workflow fails with an error naming +them. It used to skip and report success, which hid the missing secrets for +weeks. -1. **Create the Pages project** (one-off, direct-upload mode — do *not* +1. **Create the Pages project** (one-off, direct-upload mode). Do *not* connect it to Git, or Cloudflare will start building on its own and you - will have two things deploying the site): + will have two things deploying the site. ```bash npx wrangler@4 pages project create netscli-site-preview \ --production-branch=unused-production-branch ``` - The production branch is deliberately a name no PR will ever use, so the + The production branch is deliberately a name nothing deploys to, so the project has no reachable production deployment. 2. **Add two repository secrets** under Settings → Secrets and variables → @@ -353,7 +352,7 @@ Preview builds set `NETSCLI_PREVIEW=1`, which does two things that matter: - **`robots: noindex, nofollow` on every page.** Emitted by `src/layouts/Page.astro` for the landing/changelog/404 pages *and* by the - `head` entry in `astro.config.mjs` for the Starlight docs pages — those use + `head` entry in `astro.config.mjs` for the Starlight docs pages. Those use Starlight's own layout, so the first mechanism alone leaves 11 of 14 pages crawlable. - **The Cloudflare Web Analytics beacon is suppressed.** A preview is still a @@ -363,7 +362,7 @@ Preview builds set `NETSCLI_PREVIEW=1`, which does two things that matter: The workflow **verifies both before deploying** and fails the job rather than publishing an indexable or analytics-reporting preview. -Neither affects a normal build: production still emits the beacon, and only +Neither affects a normal build. Production still emits the beacon, and only the 404 carries `noindex`. ## Homebrew @@ -378,8 +377,8 @@ Two artifacts in one tap, `fstubner/homebrew-tap`: **The Cask's token is `netscli-gui`, not `netscli`.** It used to be `netscli`, sharing a token with the CLI Formula in the same tap, which made a bare `brew install netscli` ambiguous and left Homebrew as the only -registry that did not distinguish the two artifacts by name — scoop has -`netscli`/`netscli-gui`, AUR `netscli-bin`/`netscli-gui-bin`, winget +registry that did not distinguish the two artifacts by name. Scoop has +`netscli`/`netscli-gui`, AUR `netscli-bin`/`netscli-gui-bin`, and winget `fstubner.netscli`/`fstubner.netscli.gui`. `publish-homebrew-cask.sh` deletes a leftover `Casks/netscli.rb` the first @@ -387,10 +386,12 @@ time it runs against a tap that still has one. Prefer the fully-qualified `fstubner/tap/...` form in documentation so it works without a separate `brew tap` step. -`publish-homebrew.sh` sed-patches the version and awk-patches each `sha256` -that follows a matching url line. `publish-homebrew-cask.sh` regenerates the -Cask wholesale from a quoted heredoc, because the Cask block puts `sha256` -*before* `url` and a single-pass awk would need a backwards lookup. +`publish-homebrew.sh` regenerates the formula from +`packaging/homebrew/netscli.rb`, filling its `@@SHA256_*@@` placeholders with +digests it computed from the downloaded binaries, and refuses a result with a +placeholder left. `publish-homebrew-cask.sh` regenerates the Cask from a +quoted heredoc. Both jobs check out `main`, so a fix to the script or the +template reaches a re-run. Needs `HOMEBREW_TAP_TOKEN`. @@ -410,49 +411,55 @@ Needs `SCOOP_BUCKET_TOKEN`. ## winget -Two package identifiers submitted to `microsoft/winget-pkgs` by -`vedantmgoyal9/winget-releaser`, which forks the repo under your account and -opens a PR. +Two package identifiers in `microsoft/winget-pkgs`: | Identifier | Job | Install | | --- | --- | --- | | `fstubner.netscli` | `winget` | `winget install fstubner.netscli` | | `fstubner.netscli.gui` | `winget-gui` | `winget install fstubner.netscli.gui` | -**A moderator has to merge the PR** — usually hours to days. This is the one -channel that is not fully automated, and the CLA must be signed once per -account. +Each job downloads Komac, refuses it unless its SHA-256 matches the value +pinned in `publish.yml`, and runs `komac update` with the release's installer +URL. Komac reads the latest manifests from winget-pkgs, writes the new +version's from the installer, and opens a PR from the token owner's fork. +Then `komac cleanup --only-merged` deletes the fork's branches whose PRs have +merged. The token is in the environment of those two steps and nowhere else. -Both jobs poll for the relevant `.sha256` sidecar for up to 15 minutes before -invoking the action, because `release.yml` and `publish.yml` both fire on -`release: published` and race each other. Without the poll the action sees no -matching asset and fails with an empty `--urls`. +This used to be `vedantmgoyal9/winget-releaser`. Its SHA pin did not pin +what received the token, because the action installs `cargo-binstall` from a +moving branch and then whichever Komac is newest. Bump Komac by changing +`KOMAC_VERSION` and `KOMAC_SHA256` together, from the `SHA256SUMS` file on +Komac's GitHub release. -winget is the **recommended Windows install path** because it verifies the -installer against the SHA256 in the manifest. Direct MSI/NSIS downloads are -unsigned and show SmartScreen warnings — see -[`RELEASE.md`](RELEASE.md#windows-trust-and-signing-policy). Do not describe -winget as a substitute for Authenticode signing. +**A moderator has to merge the PR**, usually within hours to days. This is +the one channel that is not fully automated, and the CLA must be signed once +per account. + +Re-running a winget job does not open a second PR. Komac finds its own PR +for the version and, in CI, stops there. + +winget checks the installer against the SHA-256 in its manifest. The +installers it points at are Authenticode-signed since 0.3.3, so Windows names +the publisher instead of "unknown publisher", by winget or by direct +download. SmartScreen can still warn about a file it has seen too rarely, +which a signature alone does not change. The `packaging/winget///` directories are **reference copies** of submitted manifests, one directory per version. Do not edit an existing -version's directory to hold a different version's content — winget-pkgs keys -on the directory name, and a mismatch files a conflicting duplicate. +version's directory to hold a different version's content, because +winget-pkgs keys on the directory name and a mismatch files a conflicting +duplicate. -Needs `WINGET_TOKEN` (classic PAT, `public_repo`). +Needs `WINGET_TOKEN`, a classic PAT with `public_repo` and `workflow`, on a +bot account (see [`RELEASE.md`](RELEASE.md#the-winget-token)). ### PackageVersion carries no `v`, and five published versions do -The catalog currently holds, for `fstubner.netscli`: - -``` -0.2.0 v0.2.2 v0.2.3 v0.2.4 v0.2.5 v0.2.6 -``` - -Only `0.2.0` is right. It was submitted by hand; the other five came from the -`winget` job, which passed the git tag straight through as `PackageVersion` -before that was fixed. `fstubner.netscli.gui` is unaffected — its single -`0.2.6` was also hand-submitted. +`fstubner.netscli` has five catalog versions with a leading `v`, `v0.2.2` +to `v0.2.6`, beside `0.2.0` and the correctly named `0.3.2` and later. The +`winget` job passed the git tag straight through as `PackageVersion` before +that was fixed. `fstubner.netscli.gui` never had the problem. Its first +version, `0.2.6`, was submitted by hand. **This does not break upgrades**, checked against the client rather than assumed: @@ -465,48 +472,35 @@ netscli fstubner.netscli 0.2.0 v0.2.6 winget # offers the upgrade > winget show fstubner.netscli --version 0.2.9 # finds nothing ``` -The reason is not that winget strips the `v`. It does not: nothing in +The reason is not that winget strips the `v`. It does not. Nothing in `Versions.cpp` trims a leading letter, and `Version::Assign` only trims whitespace. What happens is that each dot-separated part is split into a leading integer and a remainder, and a part with a non-empty remainder -sorts *below* one without. `v0` parses as integer `0` with remainder -`v`; plain `0` parses as integer `0` with none. So `v0.2.6 < 0.2.6`, and +sorts *below* one without. `v0` parses as integer `0` with remainder `v`, +and plain `0` parses as integer `0` with none. So `v0.2.6 < 0.2.6`, and every `v0.2.x` in the catalog sits below any `0.3.x` on the first part alone. Same outcome, but the mechanism decides what happens next. Under normalisation `v0.2.6` and `0.2.6` would be the *same* version, and re-submitting `0.2.6` would be a duplicate. Under the real rule they are -two distinct versions and `0.2.6` is the higher one — so a correctly -formed re-submission of an already-published number would land as an -upgrade rather than being rejected. Nothing plans to do that, but it is -the kind of thing the wrong mental model licenses. - -What the bad versions do do is display a version the project never issued, and put the -two packages side by side inconsistently in `winget search`: - -``` -netscli fstubner.netscli v0.2.6 -NetsCLI Desktop fstubner.netscli.gui 0.2.6 -``` - -The next correctly-versioned release fixes the display, since `winget search` -and `winget install` use the latest version. The old directories stay in the -catalog unless someone removes them. - -**Removing them is optional and not automated.** It means a PR to -`microsoft/winget-pkgs` deleting `manifests/f/fstubner/netscli/v0.2.*/`, which -breaks anyone pinned to one of those versions with -`winget install --version`. Weigh that against a tidy version list; doing -nothing is a defensible answer. - -Do **not** set the action's `max-versions-to-keep` to prune them. That deletes -versions from the public catalog as a side effect of an ordinary release, -which is not something a release should do quietly. - -The `Resolve PackageVersion` step now asserts `MAJOR.MINOR.PATCH` and fails -the job otherwise. Stripping the `v` was already enough to produce the right -answer; the assertion exists because winget-pkgs accepted all five bad ones +two distinct versions and `0.2.6` is the higher one. A correctly formed +re-submission of an already-published number would land as an upgrade +rather than being rejected. Nothing plans to do that, but it is the kind of +thing the wrong mental model licenses. + +The bad versions only ever displayed a version the project never issued, +and since 0.3.2 the newest version in the catalog is correctly named, which +is what `winget search` and `winget install` use. The old directories stay +in the catalog unless someone removes them. **Removing them is optional and +not automated.** It means a PR to `microsoft/winget-pkgs` deleting +`manifests/f/fstubner/netscli/v0.2.*/`, which breaks anyone pinned to one of +those versions with `winget install --version`. Doing nothing is a +defensible answer. A release should not prune them as a side effect. + +The `Resolve PackageVersion` step asserts `MAJOR.MINOR.PATCH` and fails the +job otherwise. Stripping the `v` was already enough to produce the right +answer. The assertion exists because winget-pkgs accepted all five bad ones without complaint, so nothing downstream will catch a recurrence. ## AUR @@ -519,40 +513,69 @@ regenerates `.SRCINFO` server-side. | `netscli-bin` | `aur` | `yay -S netscli-bin` | | `netscli-gui-bin` | `aur-gui` | `yay -S netscli-gui-bin` | -Each job computes the asset SHA256s, renders a bumped PKGBUILD with `sed`, -and pushes. The render happens **inside `$GITHUB_WORKSPACE`**, not `/tmp` — -the deploy action runs in a container that mounts the workspace only, and a -`/tmp` path produces a confusing `bash: --command: invalid option` error. +Each job downloads the release assets, hashes the bytes and requires the +`.sha256` sidecar to agree, renders a bumped PKGBUILD from the template with +`sed`, and pushes. The render happens **inside `$GITHUB_WORKSPACE`**, not +`/tmp`, because the deploy action runs in a container that mounts the +workspace only, and a `/tmp` path produces a confusing +`bash: --command: invalid option` error. AUR has **no review step**. A bad push is live immediately. Needs `AUR_SSH_PRIVATE_KEY`. +## npm and the MCP Registry + +The `npm` job publishes the `netscli` launcher and one package per platform, +each holding a prebuilt binary downloaded from the release and checked +against its digest. That is what makes `npx netscli serve` work as an MCP +command. It publishes with npm trusted publishing, an OIDC credential +minted for the run, so no npm token exists. `NPM_TOKEN` is needed only to +publish a package that does not exist yet, see +[`packaging/README.md`](../packaging/README.md). + +The `mcp-registry` job lists `packaging/mcp-registry/server.json` in the MCP +Registry. It runs after `npm`, because the registry checks the npm package +it points at, and authenticates with the workflow's OIDC token. + +## MCP bundles + +The `mcpb` job builds five `.mcpb` bundles, one per platform, from the +published binaries, with `npx @anthropic-ai/mcpb`. It holds a read-only +token and no OIDC, because that tool's dependencies are resolved afresh on +every run. `mcpb-sign` then hashes and Sigstore-signs each bundle and +uploads it with its `.sha256`, `.sig` and `.pem`. Their signing identity is +`publish.yml`, not `release.yml`, see +[`RELEASE.md`](RELEASE.md#verifying-release-signatures). + ## When a channel fails -Every job is independent and re-runnable. `publish.yml` accepts a -`workflow_dispatch` tag input, so you can re-run a single channel without -rebuilding binaries or touching the others: +Every job is independent. If nothing in the workflow or its scripts had to +change, use **Re-run failed jobs** on the publish run. If a fix had to merge, +dispatch the one job, which picks the fix up: ```bash -gh workflow run publish.yml -f tag=vX.Y.Z +gh workflow run publish.yml -f tag=vX.Y.Z -f only= ``` -The publish scripts are idempotent — re-running a channel that already -succeeded commits nothing and exits cleanly, rather than failing on "nothing -to commit". +The job ids are `crates-io`, `npm`, `homebrew`, `scoop`, `winget`, `aur`, +`homebrew-cask`, `scoop-gui`, `winget-gui`, `aur-gui`, `mcpb` (which also +runs `mcpb-sign`) and `mcp-registry`. Without `-f only` every job runs +again. Each is safe to run twice (see +[`RELEASE.md`](RELEASE.md#when-something-fails)), but every registry is +contacted again. Failure modes worth knowing: | Symptom | Cause | | --- | --- | -| `refusing to publish malformed tag` | The tag input is not `vMAJOR.MINOR.PATCH[-prerelease]`. Deliberate — the tag reaches `sed` replacements and commit messages. | -| `checksum mismatch for ` | The `.sha256` sidecar disagrees with the actual bytes. The scripts download and re-hash rather than trusting the sidecar; investigate before overriding. | +| `refusing to publish malformed tag` | The tag input is not `vMAJOR.MINOR.PATCH[-prerelease]`. Deliberate, because the tag reaches `sed` replacements and commit messages. | +| `checksum mismatch for ` | The `.sha256` sidecar disagrees with the actual bytes. The scripts download and re-hash rather than trusting the sidecar, so investigate before overriding. | | `.sha256 is not a 64-char hex digest` | Sidecar truncated or missing. Previously this silently produced a manifest with blank hashes. | -| `never became available` after 15 min | `release.yml` did not attach the asset. Check that job first; publishing cannot proceed without it. | -| winget job fails with empty `--urls` | Asset poll passed but the action ran too early, or the package has never been accepted into winget-pkgs. | -| crates.io "no matching package named …" | An earlier crate in the order has not indexed yet. Wait and re-run; the job sleeps 90s between publishes. | +| `never became available` after 15 min | The asset is not on the release. On the normal path every asset is checked before the release goes public, so this means the release was promoted by hand. Check the release first. | +| `komac.exe digest ..., expected ...` | The Komac download does not match the pinned digest. Do not re-pin without checking the new digest against Komac's own `SHA256SUMS`. | +| winget job ends with "There is already ... pull request" | Komac found its own PR for this version and stopped. Nothing to fix. | +| a hand-run `cargo publish` fails on a crate that already has the version | crates.io refuses a version it has. The job leaves those crates out, and by hand you list only the ones still missing. | -Because crates.io publishes are **permanent** — you can yank but not delete, -and a yanked version keeps its number — a failed crates.io publish means -bumping the patch version, not retrying the same one. +Because crates.io publishes are **permanent**, a crate that went up broken +means bumping the patch version, not retrying the same one. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 13ead005..0e1174aa 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -1,59 +1,95 @@ # Release process -netscli ships through six distribution channels. Every step after "publish -the GitHub release" is automated by -[`.github/workflows/publish.yml`](../.github/workflows/publish.yml). +This document is the **process**, what a human does and in what order. For a +per-channel reference (what each channel is, what publishes to it, how to fix +it when it breaks), see [`PUBLISHING.md`](PUBLISHING.md). -This document is the **process** — what a human does, in order. For a -per-channel reference (what each channel is, what it needs, how to fix it -when it breaks), see [`PUBLISHING.md`](PUBLISHING.md). +One workflow run does the release, once the tag and the changelog are in +place. [`publish-release.yml`](../.github/workflows/publish-release.yml) +builds every asset into the draft release, checks that all of them are +there, and only then makes the release public and starts the package +managers ([`publish.yml`](../.github/workflows/publish.yml)) and the site +([`pages.yml`](../.github/workflows/pages.yml)). Until the last step the +release is a draft that nobody can see, so a build that fails leaves nothing +broken in public. -## One-time secret setup +## One-time setup -Before the first automated release runs, add these secrets at -**GitHub → Settings → Secrets and variables → Actions → New repository secret**: +### Repository secrets + +Set these under **GitHub → Settings → Secrets and variables → Actions → +New repository secret**. | Secret | Where to get it | Used by | |--------|-----------------|---------| -| `CARGO_REGISTRY_TOKEN` | [crates.io → Account Settings → API Tokens](https://crates.io/settings/tokens) → "New Token" with `publish-update` scope (limit to `netscli-core`, `netscli-mcp`, `netscli` if you want least-privilege) | `crates-io` job | -| `HOMEBREW_TAP_TOKEN` | [github.com/settings/tokens](https://github.com/settings/tokens) → fine-grained or classic PAT with `Contents: Read & Write` on `fstubner/homebrew-tap` | `homebrew` job | -| `SCOOP_BUCKET_TOKEN` | Same shape as Homebrew but for `fstubner/scoop-bucket`. May reuse the same PAT if scoped widely. | `scoop` job | -| `WINGET_TOKEN` | Classic PAT with the `workflow` scope, which GitHub's token page pairs with the whole `repo` group. `public_repo` alone is not enough: the releaser has to create a branch on your fork of `microsoft/winget-pkgs`, and without `workflow` that fails with "does not have the correct permissions to execute CreateRef". Fine-grained tokens are not supported by the releaser. | `winget` job | -| `AUR_SSH_PRIVATE_KEY` | The private half of the `~/.ssh/aur` key already registered with your AUR account (we generated this earlier). Paste the full file content (`-----BEGIN OPENSSH PRIVATE KEY-----` through `-----END OPENSSH PRIVATE KEY-----` inclusive). | `aur` job | - -Four more are **environment** secrets, set on the `release-signing` -environment (**Settings → Environments → release-signing**) rather than on -the repository, so only the jobs that sign can read them. That environment -must have no protection rules and no branch or tag restriction: it scopes the -secrets, it does not gate anything, and a restriction would stall every -release partway through its uploads. +| `CARGO_REGISTRY_TOKEN` | [crates.io → Account Settings → API Tokens](https://crates.io/settings/tokens), "New Token" with the `publish-update` scope, limited to `netscli-core`, `netscli-mcp` and `netscli` | `crates-io` job | +| `HOMEBREW_TAP_TOKEN` | [github.com/settings/tokens](https://github.com/settings/tokens), a fine-grained or classic PAT with `Contents: Read & Write` on `fstubner/homebrew-tap` | `homebrew`, `homebrew-cask` jobs | +| `SCOOP_BUCKET_TOKEN` | The same shape as the Homebrew token, for `fstubner/scoop-bucket`. One PAT can serve both if it covers both repositories. | `scoop`, `scoop-gui` jobs | +| `WINGET_TOKEN` | A classic PAT with the `public_repo` and `workflow` scopes, created on the account whose fork of `microsoft/winget-pkgs` the PRs come from. See [The winget token](#the-winget-token). | `winget`, `winget-gui` jobs | +| `AUR_SSH_PRIVATE_KEY` | The private half of the SSH key registered with your AUR account. Paste the whole file, from `-----BEGIN OPENSSH PRIVATE KEY-----` to `-----END OPENSSH PRIVATE KEY-----`. | `aur`, `aur-gui` jobs | + +Three more groups exist for narrower uses, each documented where it is used. +`NPM_TOKEN` is needed only to publish an npm package that does not exist yet +([`packaging/README.md`](../packaging/README.md)). The `MSSTORE_*` secrets +automate Microsoft Store submissions +([`MICROSOFT-STORE.md`](MICROSOFT-STORE.md)). The two `CLOUDFLARE_*` secrets +deploy site previews ([`PUBLISHING.md`](PUBLISHING.md#site-previews-cloudflare-pages)). + +### The `release-signing` environment + +Four more secrets live on the `release-signing` environment +(**Settings → Environments → release-signing**) rather than on the +repository, so only the jobs that sign can read them. | Secret | Where to get it | Used by | |--------|-----------------|---------| -| `CERTUM_EMAIL` | The e-mail address of your Certum account. | `sign-windows` job (Authenticode) | -| `CERTUM_OTP` | The TOTP seed from SimplySign, base32, or the full `otpauth://` URI. Treat it like a private key. | `sign-windows` job | -| `TAURI_SIGNING_PRIVATE_KEY` | The private key from `npm run tauri signer generate -- -w ~/.tauri/netscli-updater.key`, run in `apps/netscli-gui`. Its public half goes in `plugins.updater.pubkey` in `tauri.conf.json`. | `gui` and `sign-windows` jobs (updater signatures) | -| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | The password you gave that command. | same | +| `CERTUM_EMAIL` | The e-mail address of your Certum account. | `gui` (Windows) and `sign-windows` jobs | +| `CERTUM_OTP` | The TOTP seed from SimplySign, base32, or the full `otpauth://` URI. Treat it like a private key. | the same | +| `TAURI_SIGNING_PRIVATE_KEY` | The private key from `npm run tauri -- signer generate -w ~/.tauri/netscli-updater.key`, run in `apps/netscli-gui`. Its public half goes in `plugins.updater.pubkey` in `tauri.conf.json`. | `gui` and `sign-windows` jobs (updater signatures) | +| `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | The password you gave that command. | the same | + +Set **Deployment branches and tags** on the environment to the branch `main` +and the tag pattern `v*`. Every job that uses the environment runs from a +workflow dispatched on `main`, so nothing in the release needs more, and a +workflow pushed to any other branch cannot read the secrets. Add no required +reviewers. A reviewer would hold the release at its first signing job, which +the draft can afford, but a stolen maintainer token can approve its own +deployment through the API, so it would protect against nothing the branch +policy does not. **Back up the updater private key somewhere other than this machine.** Every installed copy of the desktop app accepts updates signed by that key and no -other. Lose it, and those copies can no longer update from inside the app. -A new key means a new build, which everyone then has to install once +other. Lose it, and those copies can no longer update from inside the app. A +new key means a new build, which everyone then has to install once themselves, by hand or through their package manager. +### The winget token + +`komac`, the tool the winget jobs run, forks `microsoft/winget-pkgs` under +the account that owns `WINGET_TOKEN` and opens its PRs from there. It needs a +classic PAT. Fine-grained tokens cannot open a PR on a repository their owner +does not own. The token needs `workflow` as well as `public_repo`, because +creating a branch on the fork fails without it ("does not have the correct +permissions to execute CreateRef"). GitHub's token page pairs `workflow` with +the whole `repo` group. + +So make the token on a separate account that exists only for this. A classic +token with those scopes can write to every repository its owner can, and on +your own account that includes this one. On a bot account with no access to +`fstubner/*`, the most it can touch is that account's fork. The account needs +the winget-pkgs CLA signed once. `publish-preflight.yml` reports which +account the token belongs to and fails if it lacks `workflow`. + ## The release flow -1. **Bump versions + CHANGELOG**, merge to main. Seven files have to move - together — see - [`PUBLISHING.md`](PUBLISHING.md#version-bumps) for the list and a - verification command; missing the GUI's three is what got the v0.2.4 - winget submission rejected. - The CHANGELOG half of this step is the heading rename only: - `## [Unreleased]` becomes `## [X.Y.Z]`. The date and the link reference - are a later commit, after the tag is pushed — see - [Dating the release](PUBLISHING.md#dating-the-release). Putting them in - here fails `check:changelog`, which asks git whether the tag exists. - Before tagging, run the local release gate: +1. **Bump the versions and the changelog heading**, and merge. Seven files + have to move together, see [`PUBLISHING.md`](PUBLISHING.md#version-bumps) + for the list and a command that checks them. Missing the GUI's three is + what got the v0.2.4 winget submission rejected. The changelog half of + this step is the heading rename only, `## [Unreleased]` to + `## [X.Y.Z]`. The date and the link come later, in step 3. + + Before merging, run the local release gate: ```bash cargo fmt --check cargo test --all --no-fail-fast @@ -66,44 +102,13 @@ themselves, by hand or through their package manager. > release must not block on it.** WebView2 Runtime 150+ ignores > `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS` on an elevated host, by design > per Microsoft, so tauri-driver cannot hand msedgedriver its - > remote-debugging port. Tracked at wry#1782; nothing on our side fixes - > it. `gui-render.yml` is schedule-only for the same reason. Desktop - > coverage is manual until a scheduled run goes green — see the installer - > smoke below, which is a real check and still required. + > remote-debugging port. Tracked at wry#1782, and nothing on our side + > fixes it. `gui-render.yml` is schedule-only for the same reason. + On Windows, PCAP-enabled source builds also need the Npcap SDK import library on `LIB` (for x64 MSVC, the directory containing `wpcap.lib` is usually `\Lib\x64`) and `C:\Windows\System32\Npcap` on `PATH` for runtime checks. - For the desktop installer smoke, run: - ```powershell - cd apps/netscli-gui - npm run tauri build - - # Installed-binary render smoke. The render harness skips its own - # Tauri build when TAURI_APP_PATH is set. - # - # Expected to fail today for the WebView2/wry#1782 reason noted above. - # Run it to see whether that has changed, but do not gate the release on - # it -- launch the installed app by hand instead and confirm it renders. - $env:TAURI_APP_PATH = "C:\Program Files\NetsCLI\netscli-gui.exe" - npm run test:tauri-render - Remove-Item Env:\TAURI_APP_PATH - ``` - For installer smoke, validate both Windows bundles: - - **NSIS:** install silently to a temporary user-writable directory, - run the installed-binary render smoke with `TAURI_APP_PATH`, then - uninstall silently and verify the app files plus - `HKCU:\Software\netscli\NetsCLI` install-location key are gone. - - **MSI:** install elevated, verify `InstallLocation` is - `C:\Program Files\NetsCLI\`, run the installed-binary render smoke, - install the next MSI over it to verify upgrade behavior, then - uninstall elevated and keep the installer logs with the release - notes. - - The MSI uses a custom WiX template so stale NSIS remembered install - paths cannot redirect a clean MSI install into an old temporary - directory. Treat any regression in these installer checks as a - release-validation blocker. The published desktop GUI installers are intentionally built without `--features pcap`. This keeps the GUI install path free of libpcap/Npcap @@ -113,187 +118,313 @@ themselves, by hand or through their package manager. the required system runtime themselves. If a future release adds PCAP-enabled GUI installers, validate launch behavior on Windows with and without Npcap installed before publishing that flavor. For local Windows - PCAP GUI experiments, use - `scripts/dev-gui-pcap.ps1` or otherwise set `CARGO_TARGET_DIR=target-pcap` - so a PCAP-linked debug binary does not replace the normal non-PCAP - `target/debug/netscli-gui.exe`. - - Also check dependency freshness: + PCAP GUI experiments, use `scripts/dev-gui-pcap.ps1` or otherwise set + `CARGO_TARGET_DIR=target-pcap`, so a PCAP-linked debug binary does not + replace the normal non-PCAP `target/debug/netscli-gui.exe`. + + The installer itself is checked in CI. + [`installer-upgrade.yml`](../.github/workflows/installer-upgrade.yml) + installs the last release's MSI and then this branch's over it, on every + pull request that touches `tauri.conf.json` or the WiX template, and + checks what a user is left with. The in-app update is not, see step 5. + + Also check dependency freshness, and if there are compatible updates, + refresh the lockfiles and run the gate again: ```bash cargo update --dry-run npm outdated --prefix apps/netscli-gui ``` - If these report compatible updates, refresh the lockfiles and rerun - the full gate before tagging. -2. **Tag and draft the release.** Either `gh release create vX.Y.Z --draft - --generate-notes` or use the web UI's "Draft a new release" button. -3. **Edit the release notes.** The public changelog page renders the GitHub - release body directly, so do not publish PR-title-only notes for a visible - product milestone. Add a short human-written overview before the generated - change list: - - what changed for users, - - why it changed, - - who benefits, - - upgrade, packaging, or runtime caveats. - - For example, the GUI redesign release should explain the move from the - simple dashboard-style shell to the tabbed desktop workflow, why richer - result details and filters were added, and what stayed compatible. -4. **Promote the draft to public.** This is the one human action that - stays in the loop. + +2. **Tag the bump commit and push the tag.** + ```bash + git tag vX.Y.Z + git push origin vX.Y.Z + ``` + Pushing a tag builds nothing. It does make release-drafter's next run + retitle its draft to this version. If the draft still shows another + version, run the **Release Drafter** workflow from the Actions tab. + +3. **Date the changelog and write the one-line summary**, and merge. In one + pull request: + - give the heading its date, `## [X.Y.Z] - YYYY-MM-DD`, the day you mean + to publish, and add the `[X.Y.Z]: …/releases/tag/vX.Y.Z` link reference. + The order is forced, see + [Dating the release](PUBLISHING.md#dating-the-release). + - add `releaseSummaries['vX.Y.Z']` to + `site/src/data/site-content/changelog.ts`, one sentence on what the + release means for someone using it. + + The release body is written from these two by `scripts/release-notes.mjs` + just before the release goes public, and it replaces whatever the draft + held. So edit `CHANGELOG.md`, not the draft. A note typed into the draft + is lost. + +4. **Check the credentials.** + ```bash + gh workflow run publish-preflight.yml + ``` + It checks the five publishing credentials and the four signing ones, and + publishes nothing. The updater key and its password sign a scratch file, + which is then checked against the public key the app embeds. GitHub PATs + commonly carry a 90-day expiry and releases here are months apart, so run + it every time. + +5. **Check an in-app update by hand** when the release changes the updater, + the installer, Tauri, or how update files are signed, and for the first + release an installed app updates to. Nothing in CI installs an update + through the app. See [Staged in-app update check](#staged-in-app-update-check). + +6. **Publish.** ```bash gh workflow run publish-release.yml -f tag=vX.Y.Z ``` - Publish through this workflow, not `gh release edit --draft=false` and - not the web UI's publish button. `release-drafter` rewrites its draft on - every push to `main`, and it overwrites whatever release it picked up if - that release goes public mid-run — one second of overlap is enough, and - that is how v0.3.1 lost its tag. `publish-release.yml` holds a lock the - drafter shares; the other two routes hold nothing. "Merge the changelog, - then publish" puts a push and a publish back to back, so the overlap is - the normal sequence rather than bad luck. - - The workflow refuses a tag that does not exist or is already published, - flips the draft, reads it back to confirm the release is public and still - carries its tag, then starts `release.yml` (binaries + GUI installers) - and `publish.yml` (package managers). It dispatches those two explicitly - because GitHub suppresses `release: published` for anything done with a - workflow token — `workflow_dispatch` is one of the two documented - exceptions. -5. **Watch the dashboard.** From the release page or the Actions tab: - - `Release` workflow: 11 CLI assets + 5 GUI installers attached. - - `Publish to package managers` workflow: 9 jobs — a CLI and a GUI job - for each of Homebrew, Scoop, winget and AUR, plus `crates-io`. - - The `homebrew`, `scoop`, and `aur` jobs poll for asset availability - (up to 15 minutes) so they tolerate the parallel race against - `release.yml`. - -6. **If something fails**, re-run just that job from the Actions tab — - `workflow_dispatch` accepts a tag input so you can re-run a specific - channel without rebuilding binaries or re-running the others. - -## What still needs human attention - -- **Winget moderator approval.** The action opens a PR to - `microsoft/winget-pkgs`; a community moderator merges it within a few - hours to days. CLA must be signed once per account (you've done this). -- **GUI-channel manifests are now automated.** Four GUI jobs live in - `publish.yml` alongside their CLI siblings — `homebrew-cask`, - `scoop-gui`, `winget-gui`, `aur-gui` — and re-stamp the manifests on - every release the same way the CLI ones do. Templates remain under - [`packaging/`](../packaging/) for reference; deployed copies live in - the tap / bucket / AUR / winget-pkgs. - -## Sigstore signature verification - -`release.yml` signs every CLI and GUI asset keylessly via `cosign sign-blob`, -using the GitHub Actions OIDC token as the signing identity (no cosign key -material is stored anywhere). Each asset gets a `.sig` and `.pem` uploaded -alongside it. This is documented for consumers in README.md's -"Verifying Release Signatures" section, but nothing in the current install -scripts or packaging manifests (Homebrew/Winget/Scoop/AUR) verifies it -automatically — those channels rely on their own hash-verification instead -(see below). If a downstream package manager integration is ever added that -wants to verify the cosign signature automatically, the verify command is: + Always through this workflow, never `gh release edit --draft=false` and + never the web UI's publish button. `release-drafter` rewrites its draft on + every push to `main`, and if the release goes public in the middle of that + rewrite, the rewrite turns it back into a draft or worse. One second of + overlap is enough, and that is how v0.3.1 lost its tag. + `publish-release.yml` holds a lock the drafter shares. The other two + routes hold nothing, and they also skip the build and the asset check. + + The run does, in order: + - **prepare.** Refuses a malformed tag, a run that is not on `main`, a tag + that was not pushed, a release that is not a draft, and a version with + no `CHANGELOG.md` section. + - **build.** Runs [`release.yml`](../.github/workflows/release.yml) + against the draft. Every CLI binary and desktop installer is built, + signed (Authenticode for the Windows files, Sigstore for all) and + uploaded to the draft with its `.sha256`, `.sig` and `.pem`, then + `latest.json` is built for the in-app updater and every update + signature in it is checked against the app's public key. + - **promote.** Checks the draft carries all 18 files with their three + sidecars, that every `.sha256` matches the digest GitHub computed for the + uploaded file, and that `latest.json` announces this version, points at + this tag, and carries signatures bound to this version. Then it writes + the release body, makes the release public, reads it back to prove it is + public and still carries its tag, and starts `publish.yml` and + `pages.yml`. + + The lock is held for the whole run, so release-drafter waits until the + release is public. There is one thing it cannot prevent. When a drafter + run is already going, this run waits for it, and GitHub cancels a waiting + run if another push to `main` arrives in that time. That happens before + anything is done, so dispatch it again. + +7. **Watch it.** + - The **Publish release** run, with the build's jobs inside it as + `Build / ...`. The release stays a draft until its last job. + - **Publish to package managers**, 13 jobs and a summary. Its summary table + says which registries took the release. + - **Deploy GitHub Pages**, which updates the version netscli.com names and + copies the new MSI to `netscli.com/download/vX.Y.Z/` for the Microsoft + Store. + +8. **After publishing.** + - Update one installed copy of the previous release from inside the app, + on Windows at least. It is the first real use of the published + `latest.json`. + - Winget PRs need a moderator to merge them, usually within hours to + days. + - Submit the Store listing once the site has deployed, with + `gh workflow run msstore.yml -f tag=vX.Y.Z` (see + [`MICROSOFT-STORE.md`](MICROSOFT-STORE.md)). + +## When something fails + +**The build fails** (a signing step, one platform's build). The release is +still a draft, so nothing public is affected. Fix the cause, then open the +failed **Publish release** run and use **Re-run failed jobs**. It re-runs the +failed jobs and every job after them, including `promote`. `sign-windows` +and the `latest.json` job read their scripts from `main` when they run, so a +fix merged to a script reaches the re-run. A fix to a workflow file does not, +because a re-run uses the workflow as it was. For that, dispatch +`publish-release.yml` again, which rebuilds every asset into the draft and +replaces what is there. The unsigned Windows files are kept for one day, so +after a day a re-run of `sign-windows` finds nothing to sign. Dispatch again +instead. + +**The asset check fails.** Its log names every missing or mismatched file. +Usually a build job uploaded only part of its files. Re-run the failed jobs +as above. + +**Promoting fails.** If the release is still a draft, re-run the failed jobs. +If it went public and the job failed afterwards, its error says which of +`publish.yml` and `pages.yml` did not start. Start them by hand. A re-run +will not do it for you, on purpose, because it cannot tell whether +`publish.yml` already started, and a second fan-out contacts every registry +again. + +**A package manager job fails.** If nothing in the workflow or its scripts +had to change, use **Re-run failed jobs** on the publish run. If a fix had to +merge, dispatch that one job, which picks the fix up: +```bash +gh workflow run publish.yml -f tag=vX.Y.Z -f only= +``` +The job ids are `crates-io`, `npm`, `homebrew`, `scoop`, `winget`, `aur`, +`homebrew-cask`, `scoop-gui`, `winget-gui`, `aur-gui`, `mcpb` (which also +runs `mcpb-sign`) and `mcp-registry`. A run without `only` runs all of them +again. + +Re-running is safe for each of them. `crates-io` skips crates that already +have the version. `npm` and `mcp-registry` skip what their registries +already have. Komac stops when it finds its own PR for the version. The tap +and bucket scripts, and the AUR action, commit nothing when nothing changed. +`mcpb` uploads with `--clobber`. + +## Staged in-app update check + +Nothing in CI tests the in-app updater. No job installs an update the way a +user does, through the app, with its UAC prompt and relaunch. This is the +check by hand, against a local copy of the release flow. It uses its own +throwaway key and a local server, so it can run before anything is +published. Do it on a Windows machine or VM where NetsCLI is not installed, +because the test builds use the real app's identity and would replace it. +Every command runs in `apps/netscli-gui`. + +1. Make a throwaway key pair. With `--ci` it has no password. + ```powershell + npm run tauri -- signer generate --ci -w $env:TEMP\staged.key + ``` +2. Write `$env:TEMP\staged-old.json`, which turns this build into version + 0.98.0 that trusts the throwaway key and checks a local server. Put the + contents of `$env:TEMP\staged.key.pub` in `pubkey`. + ```json + { + "version": "0.98.0", + "plugins": { + "updater": { + "pubkey": "", + "endpoints": ["http://127.0.0.1:8765/latest.json"], + "dangerousInsecureTransportProtocol": true + } + } + } + ``` + Copy it to `$env:TEMP\staged-new.json` and change the version to + `0.99.0`. +3. Build and install the old one, then build the new one. + ```powershell + npm run tauri -- build --bundles msi --config $env:TEMP\staged-old.json + msiexec /i (Resolve-Path ..\..\target\release\bundle\msi\NetsCLI_0.98.0_x64_en-US.msi) + npm run tauri -- build --bundles msi --config $env:TEMP\staged-new.json + ``` +4. Serve the new MSI with an updater signature bound to its version, as the + release signs it. Leave the password prompt empty. + ```powershell + $serve = New-Item -ItemType Directory -Force $env:TEMP\staged-serve + Copy-Item ..\..\target\release\bundle\msi\NetsCLI_0.99.0_x64_en-US.msi $serve\netscli-gui-windows-x86_64.msi + npm run tauri -- signer sign -f $env:TEMP\staged.key --app-version 0.99.0 $serve\netscli-gui-windows-x86_64.msi + ``` + Then write `$serve\latest.json`, with the contents of the `.msi.sig` file + in `signature`, and serve the folder: + ```json + { + "version": "0.99.0", + "notes": "Staged update check", + "pub_date": "2026-01-01T00:00:00Z", + "platforms": { + "windows-x86_64-msi": { + "signature": "", + "url": "http://127.0.0.1:8765/netscli-gui-windows-x86_64.msi" + } + } + } + ``` + ```powershell + python -m http.server 8765 --bind 127.0.0.1 --directory $serve + ``` +5. Start the installed 0.98.0. With update checks on in Settings, it offers + 0.99.0. Install it, accept the UAC prompt, and check that the app closes, + the installer runs without asking anything, the app comes back, and + **About** says 0.99.0. +6. Uninstall NetsCLI and delete `staged.key`. + +To see the version binding refuse something, sign the MSI again without +`--app-version`, reinstall 0.98.0, and start it. The update should fail with +an error about the signature not specifying its version. + +## Verifying release signatures + +Every release asset that carries a program has a Sigstore signature, made +keylessly with `cosign sign-blob` and the GitHub Actions OIDC token, so no +signing key is stored anywhere. Each asset `` has `.sig` and +`.pem` beside it. For the binaries and installers: ```bash cosign verify-blob \ --signature .sig --certificate .pem \ - --certificate-identity-regexp 'https://github.com/fstubner/netscli/.github/workflows/release\.yml@.*' \ + --certificate-identity-regexp '^https://github\.com/fstubner/netscli/\.github/workflows/release\.yml@refs/(heads/main|tags/v[0-9.]+)$' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ``` -## Windows trust and signing policy - -Windows Authenticode signing is deferred for now. The recommended Windows -install path is Winget: - -```powershell -winget install fstubner.netscli.gui -``` - -Winget validates the installer against the SHA256 hash in the submitted -manifest after the `microsoft/winget-pkgs` review path. Keep that package -ID and hash-verified install path prominent in README and release notes. - -Direct GitHub MSI/NSIS downloads remain available, but they are currently -unsigned and can show "unknown publisher" or SmartScreen warnings. Do not -claim that Winget replaces Authenticode signing; describe it as the -preferred hash-verified install channel until a paid or Store signing path -is adopted. - -## Future Microsoft Store channel - -NetsCLI Desktop can be submitted to the Microsoft Store later as an -`EXE or MSI app`, but it is a separate distribution channel from Winget. -Before attempting it, prepare: - -- Partner Center developer enrollment and reserved app name. -- A Store-specific Tauri Windows bundle config using the **offline** - WebView2 installer mode. The default bundle now uses - `embedBootstrapper` (`apps/netscli-gui/src-tauri/tauri.conf.json`), - which removes the elevated network fetch the old `downloadBootstrapper` - default performed at install time but still needs a connection for the - runtime itself. The Store requires `offlineInstaller`, which embeds the - full runtime and adds roughly 130 MB to the installer -- worth taking - only for that channel, or if offline installs become a requirement. -- Authenticode signing with a certificate that chains to a CA in the - Microsoft Trusted Root Program. Sigstore release signatures are useful - for artifact provenance, but they do not replace Store-required Windows - code signing for EXE/MSI submissions. -- A versioned HTTPS installer URL for each submitted release. Do not - reuse mutable `latest` URLs for Store submissions. -- Silent standalone install behavior for the MSI/EXE. UAC is allowed, - but the installer should not show normal setup UI and must not be a - downloader stub. - -Relevant references: -[Tauri Microsoft Store guide](https://v2.tauri.app/distribute/microsoft-store/) -and -[Microsoft EXE/MSI package requirements](https://learn.microsoft.com/windows/apps/publish/publish-your-app/msi/app-package-requirements). - -## Future update notifications - -Winget users update through `winget upgrade fstubner.netscli.gui` or -`winget upgrade --all`; Winget does not push app-owned update toasts. -If the GUI should proactively announce new releases, add a desktop-side -update check that compares the running version with GitHub Releases or a -Tauri updater manifest, then show a short in-app toast with a link to the -release. Keep installation itself owned by the selected channel -(Winget, GitHub installer, Homebrew cask, Scoop, AUR, or a future Store -listing) so the app does not invent a competing updater path. +The identity names `release.yml` on `main`, where every release since +v0.3.1 was built, or on a release tag, where earlier ones were. A looser +`release\.yml@.*` would accept a signature made by that file on any branch +anyone with write access pushes. The `.mcpb` bundles are signed after the +release is public, by `publish.yml`, so for them use +`'^https://github\.com/fstubner/netscli/\.github/workflows/publish\.yml@refs/(heads/main|tags/v[0-9.]+)$'`. +The tag arm covers a release promoted by hand, which starts `publish.yml` +from the tag. + +`scripts/install.sh` runs this check when `cosign` is installed and refuses +a binary that fails it. The package managers check the SHA-256 in their own +manifests instead. + +## Windows signing + +Every Windows executable and installer is Authenticode-signed with a Certum +cloud certificate, since 0.3.3. The `sign-windows` job signs the two CLI +`.exe` files and the MSI, on Linux, with `scripts/release/sign-windows.sh`. +The desktop app's own `.exe` has to be signed before WiX packs it into the +MSI, so the bundler does that during the build, through +`scripts/release/sign-windows-pe.ps1`. Both read back every signature they +make, and the build unpacks the finished MSI and checks the program inside. +`scripts/install.ps1` checks the signature of the `netscli.exe` it +downloads. + +## The in-app updater + +The desktop app checks +`https://github.com/fstubner/netscli/releases/latest/download/latest.json` +and installs an update only if its signature verifies against the public key +compiled into the app. The app also sets `requireSignedVersion`, so it +refuses an update whose signature is not bound to the version `latest.json` +announces. That stops an edited `latest.json` from pairing a new version +number with an older installer. Every update file is signed with +`--app-version`, and `scripts/release/updater-manifest.mjs` refuses to build +`latest.json` from a signature without it. ## GUI install commands | Platform | Command | |----------|---------| | Windows | `winget install fstubner.netscli.gui` | -| macOS | `brew install --cask fstubner/tap/netscli-gui` | -| Linux (any distro, AppImage) | `yay -S netscli-gui-bin` | | Windows (Scoop) | `scoop install netscli-gui` | +| macOS | `brew install --cask fstubner/tap/netscli-gui` | +| Arch Linux | `yay -S netscli-gui-bin` | +| Other Linux | the `.AppImage` or `.deb` from the release | -## Action pin policy +## Pins -Every third-party action in `release.yml` and `publish.yml` is pinned to a -**commit SHA**, with the human-readable version in a trailing comment: +Every third-party action in every workflow is pinned to a **commit SHA**, +with the human-readable version in a trailing comment: ```yaml -uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 +uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 ``` -Both workflows hold `contents: write` and `id-token: write`, so a retagged -or compromised action could exfiltrate the OIDC token and mint Fulcio -certificates as this repository. Tag refs are mutable; SHAs are not. - -Dependabot updates SHA pins and rewrites the comment. When bumping by hand, -resolve the tag first: +Tag refs are mutable and SHAs are not, and the release workflows hold +`contents: write` and `id-token: write`, so a retagged action could forge a +signature as this repository. Dependabot updates the SHA pins and rewrites +the comment. When bumping by hand, resolve the tag first: ```bash gh api repos///commits/ --jq .sha ``` -The workflows that only hold `contents: read` (`ci.yml`, `site.yml`, -`audit.yml`, `gui-render.yml`) still use tag refs — the blast radius there -is a failed build, not a forged signature. +The tools the workflows download are pinned by version and by SHA-256. They +are Komac, ssign, the Certum intermediate certificate, the Npcap SDK, +appimagetool and mcp-publisher, and Dependabot cannot see them. Bump them by hand, from the +upstream release's own checksums, and say where the digest came from in the +comment beside it. From a5b4c152c81b9bd1fd4b6332b9b64a64db502d2f Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:39:45 +0100 Subject: [PATCH 21/23] changelog: 0.3.5 entries for the release and installer changes Changed: releases are published only once complete; MCP bundles carry checksums and signatures. Fixed: the PowerShell installer no longer closes the window when it fails. Security: both install scripts check signatures; the desktop app requires update signatures bound to their version. --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac9cd2f6..320e6085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,18 @@ keeps a bare heading and collects entries. Felix Stubner. Upgrading from an earlier version leaves one entry, under the new name. +- **A release goes public only once every file is in it.** Each release used + to go public first and receive its files afterwards, and until they + arrived the Windows one-line installer, the desktop app's update check and + the package managers that had already updated pointed at files that were + not there. For 0.3.4 the Windows files and the update manifest arrived + 1 h 38 min after the release went public. Releases are now built as + drafts, checked file by file, and published last. + +- **The MCP bundles come with checksums and signatures.** The `.mcpb` files + were the only release files that carry a program and had no `.sha256`, + `.sig` or `.pem`. They now have all three, like every other file. + ### Fixed - **Discovery on Windows listed devices that had left the network.** The @@ -67,6 +79,27 @@ keeps a bare heading and collects entries. `--version` and gives up after 3 seconds. A program still running at that point was left behind. It is now stopped. +- **The Windows one-line installer no longer closes PowerShell when it + fails.** Run as `iwr ... | iex`, the installer's `exit` ended the + PowerShell session itself, so the window closed and took the error message + with it. It now stops with an error and leaves the window open. + +### Security + +- **The install scripts check signatures, not only checksums.** A checksum + from the same release proves a download is intact, not who built it. + `install.ps1` now refuses a `netscli.exe` whose Authenticode signature is + not valid or not the project's, and `install.sh` checks the Sigstore + signature when `cosign` is installed. Both say what they checked. + `install.sh` also downloads over HTTPS only, redirects included, and a + download cut short runs nothing. + +- **The desktop app installs an update only if its signature names the + version.** The update manifest itself is not signed, so someone able to + edit it could offer an older, validly signed installer under a newer + version number. The app now requires each update's signature to carry the + version it was made for, and every update file is signed that way. + ## [0.3.4] - 2026-10-04 ### Added From 2ad0f331668d0d3689f411a566ac6dcfb28f3a2b Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 19:45:14 +0100 Subject: [PATCH 22/23] Correct what the v0.3.4 outage broke The comments, the 0.3.5 changelog entry and the H6 commit message said that every package manager that had already published pointed at missing files. The v0.3.4 publish run (37166116245) shows otherwise: the jobs that published early (crates.io, the Homebrew formula and cask, both AUR packages) used macOS and Linux files that were already there, and the jobs that needed the Windows files (npm, MCP bundles, Scoop, winget) failed and were run again. What had nothing to download in the 1 h 38 min were install.ps1 and the in-app updater. --- .github/workflows/publish-release.yml | 6 +++--- .github/workflows/release.yml | 8 ++++---- CHANGELOG.md | 7 +++---- scripts/release/check-release-assets.mjs | 7 ++++--- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 0cc652f4..0d00f14a 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -17,9 +17,9 @@ name: Publish release # # The release used to go public first and be built afterwards. v0.3.4 had # no Windows installer and no latest.json for 1 h 38 min after it was -# published, and install.ps1, the in-app updater and every package manager -# that had already published pointed at files that did not exist. Now a -# failed build leaves a draft nobody can see. Fix it and re-run, see +# published. In that time install.ps1 and the in-app updater found +# nothing, and the publish jobs that needed the Windows files failed and +# had to be run again. Now a failed build leaves a draft nobody can see. Fix it and re-run, see # docs/RELEASE.md. # # It also holds a lock. release-drafter rewrites its draft by listing every diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dee1b024..4d108a58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,10 +6,10 @@ name: Release # for it, checks the assets, and only then makes the release public. It # used to be the other way round: the release went public first and was # built afterwards. v0.3.4 had no Windows installer and no latest.json for -# 1 h 38 min after it was published, and in that window install.ps1, the -# in-app updater and every package manager that had already published -# pointed at files that did not exist. v0.3.3's Windows files were 1 h 06 -# min late. +# 1 h 38 min after it was published. In that window install.ps1 and the +# in-app updater found nothing, and the publish jobs that needed the +# Windows files failed and had to be run again. v0.3.3's Windows files were +# 1 h 06 min late. # # There is deliberately no `release: published` trigger any more. Promoting # the draft is the last step, after this has finished, and a build started diff --git a/CHANGELOG.md b/CHANGELOG.md index 320e6085..8ce1cb6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,10 +48,9 @@ keeps a bare heading and collects entries. - **A release goes public only once every file is in it.** Each release used to go public first and receive its files afterwards, and until they - arrived the Windows one-line installer, the desktop app's update check and - the package managers that had already updated pointed at files that were - not there. For 0.3.4 the Windows files and the update manifest arrived - 1 h 38 min after the release went public. Releases are now built as + arrived the Windows one-line installer and the desktop app's update check + found nothing to download. For 0.3.4 the Windows files and the update + manifest arrived 1 h 38 min after the release went public. Releases are now built as drafts, checked file by file, and published last. - **The MCP bundles come with checksums and signatures.** The `.mcpb` files diff --git a/scripts/release/check-release-assets.mjs b/scripts/release/check-release-assets.mjs index eef4e7ef..01a9c577 100644 --- a/scripts/release/check-release-assets.mjs +++ b/scripts/release/check-release-assets.mjs @@ -12,9 +12,10 @@ * * Why it exists: a release used to go public before its files did. v0.3.4 * had no Windows installer and no latest.json for 1 h 38 min after it was - * published, and install.ps1, the in-app updater and every package manager - * that had already published pointed at nothing. Now the release is built - * as a draft and stays one until this passes, so "public" means "complete". + * published. In that time install.ps1 and the in-app updater found + * nothing, and the publish jobs that needed the Windows files failed. Now + * the release is built as a draft and stays one until this passes, so + * "public" means "complete". * * What it checks, for each of the 18 files release.yml builds: the file * and its .sha256, .sig and .pem are all there and none is empty, and the From b2cbdbe5525c62ec26822fdde7c8caa1393d2da5 Mon Sep 17 00:00:00 2001 From: Felix Stubner Date: Wed, 7 Oct 2026 20:18:05 +0100 Subject: [PATCH 23/23] Signing environment: main only. Say what Komac's PR check really does. Two corrections from an independent review of this branch. The docs and workflow comments told the maintainer to allow deployments of release-signing from `main` and `v*` tags. Nothing that signs runs from a tag any more, and with no ruleset on `v*` tags, anyone who can push could push a `v` tag onto a commit whose workflow reads the signing secrets. They now say `main` only. Komac 2.16.0's existing-PR search (get_existing_pull_request.rs) has no state filter, so a PR a moderator closed also stops it, and the job then exits 0 without submitting. The comments and docs said it found "its own PR" and that there was nothing to fix. They now say what happens and how to resubmit by hand with --skip-pr-check. --- .github/workflows/publish-preflight.yml | 5 +++-- .github/workflows/publish-release.yml | 5 +++-- .github/workflows/publish.yml | 12 ++++++++---- .github/workflows/release.yml | 13 ++++++------- docs/PUBLISHING.md | 19 ++++++++++++++++--- docs/RELEASE.md | 13 ++++++++----- 6 files changed, 44 insertions(+), 23 deletions(-) diff --git a/.github/workflows/publish-preflight.yml b/.github/workflows/publish-preflight.yml index cadd35cc..866b46a1 100644 --- a/.github/workflows/publish-preflight.yml +++ b/.github/workflows/publish-preflight.yml @@ -15,8 +15,9 @@ name: Publish preflight # build. The `signing` job finds it before a release is started. # # Every request below is a read. Nothing is created, forked, pushed or -# tagged. Run it before publishing a release, from main: the `signing` job -# uses the release-signing environment, which admits main and v* tags only. +# tagged. Run it before publishing a release, from main. The `signing` job +# uses the release-signing environment, which deploys from main only (its +# settings, see docs/RELEASE.md). # # No secret value is ever echoed. The checks report HTTP status codes and # exit codes only, so a failure says which credential is bad without diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 0d00f14a..8714d2ed 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -120,8 +120,9 @@ jobs: fi # The signing jobs use the release-signing environment, which only - # admits deployments from main and v* tags. From any other branch - # the build would run and then be refused at its first signing job. + # deploys from main (its settings, see docs/RELEASE.md). From any + # other branch the build would run and then be refused at its first + # signing job. if [[ "$GITHUB_REF" != "refs/heads/${DEFAULT_BRANCH}" ]]; then echo "::error::dispatch this from ${DEFAULT_BRANCH}, not ${GITHUB_REF}." exit 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 520191a9..60913714 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -333,10 +333,14 @@ jobs: # digest (KOMAC_* at the top of this file), and the token in the # environment of the two steps that use it and of nothing else. # - # Re-running it does not open a second PR. Komac looks for an existing PR - # for the same package and version first, and in CI it stops there rather - # than asking (src/commands/utils/mod.rs, `prompt_existing_pull_request`, - # at v2.16.0). + # Re-running it does not open a second PR. Komac first searches + # winget-pkgs for a PR whose title names this package and version, and in + # CI it stops there, exiting 0, rather than asking (v2.16.0, + # src/github/graphql/get_existing_pull_request.rs and + # src/commands/utils/mod.rs). The search ignores the PR's state, so a PR a + # moderator closed counts too, and then a re-run goes green without + # submitting anything. To resubmit after a closed PR, run Komac by hand + # with --skip-pr-check (docs/PUBLISHING.md). winget: name: Winget if: inputs.only == '' || inputs.only == 'winget' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d108a58..d42adc6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,9 +54,9 @@ jobs: # release is still a draft. # # The default branch, because the `release-signing` environment the - # signing jobs use allows deployments from `main` and `v*` tags only. A run - # from anywhere else would build and then be refused at its first signing - # job. + # signing jobs use deploys from `main` only (its settings, see + # docs/RELEASE.md). A run from anywhere else would build and then be + # refused at its first signing job. guard: name: Check the release is a draft runs-on: ubuntu-latest @@ -389,8 +389,8 @@ jobs: timeout-minutes: 60 # For TAURI_SIGNING_PRIVATE_KEY, the in-app updater's signing key, and on # Windows the Certum credentials. Same environment as sign-windows. It - # admits deployments from `main` and `v*` tags only, which the `guard` - # job checks before anything is built. + # deploys from `main` only, which the `guard` job checks before + # anything is built. environment: release-signing permissions: contents: write @@ -846,8 +846,7 @@ jobs: # so the runner's OS stops mattering. See scripts/release/sign-windows.sh. # # `environment: release-signing` scopes the credentials to this job. The - # environment admits deployments from `main` and `v*` tags only, and has - # no required reviewers. A reviewer would hold the release at this job, + # environment deploys from `main` only, and has no required reviewers. A reviewer would hold the release at this job, # but on a draft that holds nothing public, so it is possible now that the # release is built before it is published. Not added, because a stolen # maintainer token can approve its own deployment through the API. diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md index 6d72b481..97236c82 100644 --- a/docs/PUBLISHING.md +++ b/docs/PUBLISHING.md @@ -435,8 +435,20 @@ Komac's GitHub release. the one channel that is not fully automated, and the CLA must be signed once per account. -Re-running a winget job does not open a second PR. Komac finds its own PR -for the version and, in CI, stops there. +Re-running a winget job does not open a second PR. Komac first searches +winget-pkgs for a PR whose title names the package and version, from any +author and in any state, and in CI it stops there and exits successfully. +That includes a PR a moderator closed, so after a rejection a re-run goes +green and submits nothing. To resubmit, fix what the moderator asked for and +run Komac by hand with `--skip-pr-check`: + +```powershell +$env:GITHUB_TOKEN = "" +komac update fstubner.netscli --version X.Y.Z --urls https://github.com/fstubner/netscli/releases/download/vX.Y.Z/netscli-windows-x86_64.exe --submit --skip-pr-check +``` + +For the desktop app it is `fstubner.netscli.gui` with the +`netscli-gui-windows-x86_64.msi` URL. winget checks the installer against the SHA-256 in its manifest. The installers it points at are Authenticode-signed since 0.3.3, so Windows names @@ -574,7 +586,8 @@ Failure modes worth knowing: | `.sha256 is not a 64-char hex digest` | Sidecar truncated or missing. Previously this silently produced a manifest with blank hashes. | | `never became available` after 15 min | The asset is not on the release. On the normal path every asset is checked before the release goes public, so this means the release was promoted by hand. Check the release first. | | `komac.exe digest ..., expected ...` | The Komac download does not match the pinned digest. Do not re-pin without checking the new digest against Komac's own `SHA256SUMS`. | -| winget job ends with "There is already ... pull request" | Komac found its own PR for this version and stopped. Nothing to fix. | +| winget job ends with "There is already an open pull request" or "a merged" one | Komac found the PR for this version and stopped. Nothing to fix. | +| winget job ends with "There is already a closed pull request" | A moderator closed the PR and nothing was resubmitted. See [winget](#winget) for resubmitting. | | a hand-run `cargo publish` fails on a crate that already has the version | crates.io refuses a version it has. The job leaves those crates out, and by hand you list only the ones still missing. | Because crates.io publishes are **permanent**, a crate that went up broken diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 0e1174aa..2e13b964 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -49,10 +49,12 @@ repository, so only the jobs that sign can read them. | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | The password you gave that command. | the same | Set **Deployment branches and tags** on the environment to the branch `main` -and the tag pattern `v*`. Every job that uses the environment runs from a -workflow dispatched on `main`, so nothing in the release needs more, and a -workflow pushed to any other branch cannot read the secrets. Add no required -reviewers. A reviewer would hold the release at its first signing job, which +and nothing else. Every job that uses the environment runs from a workflow +dispatched on `main`, so nothing in the release needs more, and a workflow +pushed to any other branch cannot read the secrets. Leave tags out. Nothing +that signs runs from a tag any more, and with no ruleset on `v*` tags, +anyone who can push could push a `v` tag onto a commit whose workflow reads +the secrets. Add no required reviewers. A reviewer would hold the release at its first signing job, which the draft can afford, but a stolen maintainer token can approve its own deployment through the API, so it would protect against nothing the branch policy does not. @@ -267,7 +269,8 @@ again. Re-running is safe for each of them. `crates-io` skips crates that already have the version. `npm` and `mcp-registry` skip what their registries -already have. Komac stops when it finds its own PR for the version. The tap +already have. Komac stops when winget-pkgs has a PR for the version, even a +closed one (see [`PUBLISHING.md`](PUBLISHING.md#winget)). The tap and bucket scripts, and the AUR action, commit nothing when nothing changed. `mcpb` uploads with `--clobber`.