Skip to content

Release pipeline: build into the draft and publish only when every file is there; pinned winget tooling; signed installs - #557

Merged
fstubner merged 24 commits into
mainfrom
fix/release-pipeline
Oct 7, 2026
Merged

fstubner merged 24 commits into
mainfrom
fix/release-pipeline

Conversation

@fstubner

@fstubner fstubner commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Audit fixes for CI, the release flow and the supply chain (areas/ci-release.md, local only). The release docs are rewritten to match.

The two Highs

  • Releases go public only once every file exists (H6). publish-release.yml is now three jobs:

    1. prepare checks the tag, that the run is on main, that the release is a draft, and that the changelog has the section.
    2. build calls release.yml as a reusable workflow against the still-draft release.
    3. promote runs check-release-assets.mjs over every expected file, latest.json included, then writes the notes, publishes, and dispatches publish.yml and pages.yml.

    release.yml no longer answers release: published, so promoting can't start a second build. A failed Windows signing leg leaves an invisible draft, which "Re-run failed jobs" can finish. With 0.3.4's outage state recreated, the asset check fails and names the 13 missing files.

  • The winget token no longer reaches unpinned code (H5). Both winget jobs download Komac 2.16.0 and refuse it unless its SHA-256 matches the pinned value. Komac's own SHA256SUMS and the GitHub API agree on that value. The token is only in the komac update --submit and komac cleanup steps.

Also fixed

  • Updater signatures are bound to their version, and the app requires that (requireSignedVersion). The manifest builder and the asset check refuse a signature with no version.
  • Signing secrets are out of the compile and npm install steps. tauri build --no-bundle runs with no secrets, then tauri bundle runs with them.
  • Every checkout uses persist-credentials: false.
  • Install scripts check signatures and fail closed on a bad one. install.ps1 requires a valid Authenticode signature on netscli.exe from the expected signer. install.sh checks the cosign identity when cosign is installed.
  • crates.io: the token is off the command line and out of the verify build. A re-run skips crates that are already published.
  • pages.yml is split into build, stage and deploy jobs, each with its own permissions. Staged MSIs are checked with osslsigncode.
  • The .mcpb bundles get checksums and signatures.
  • CI and preflight:
    • PSScriptAnalyzer runs on the release PowerShell scripts.
    • The preflight checks the signing credentials and the winget token's scope.
  • The site shows only published releases as released, using the Releases API at deploy time. 0.3.5 reads "Not yet released" until it is public.
  • Housekeeping:
    • Dependabot covers /scripts/mcp-smoke and /scripts.
    • Stale cargo-audit ignores are removed.
    • The last unpinned action is pinned.
    • Tool versions are pinned.
    • Timeouts are added.
  • docs/RELEASE.md and docs/PUBLISHING.md are rewritten to describe this flow. RELEASE.md gains a staged in-app update check.

Corrections to the audit

  • 0.3.4's package managers did not publish links to missing files. The jobs that needed the Windows files failed instead. What broke was install.ps1 and the in-app updater.
  • The deployment policy for release-signing should be main only, not main plus v* tags. Nothing that signs runs from a tag any more, and with no tag ruleset a v tag would be a way in.

Checked

  • All 15 workflow and config files parse.
  • lib_test.sh passes, and node --test on the release scripts passes 20 of 20.
  • install_test.sh passes 11 of 11. 8 of those fail against the old install.sh.
  • The asset check passes on real 0.3.4 data and fails on the outage state.
  • The pinned Tauri CLI with a throwaway key writes version:0.3.5 into the signature.
  • The site builds three ways: without the releases list, with the real one, and with an empty one. check:changelog and check:structured-data pass in all three.
  • An independent review found 4 issues. 2 are fixed and 2 are documented.
  • Not run here, because the tools are missing: PSScriptAnalyzer, shellcheck, cosign and minisign. CI runs the first two.

Risk

  • The new flow has never run end to end, and 0.3.5 will be its first real run. If anything fails before promote, the release stays a draft, which is the point of the change.
  • Run publish-preflight first.

By hand, before publishing 0.3.5

  • Set release-signing deployments to main only.
  • Move the v0.3.5 tag to the merge commit, so 0.3.5 ships requireSignedVersion.
  • Move winget to a bot account with no access to fstubner/*, then rotate WINGET_TOKEN.
  • Later: crates.io trusted publishing, and a ruleset protecting v* tags.

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).
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.
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.
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.
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).
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.
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).
…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).
… 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=<job>` for re-running one registry, and
the `only` comment lists the job ids.

Audit M21, M23, ci-release L-4, L-5, L-6.
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).
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.
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).
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).
…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.
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.
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).
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.
- 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).
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.
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=<job>` 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.
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.
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.
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-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Site preview: https://pr-557.netscli-site-preview.pages.dev

Built from 50947b0 with NETSCLI_PREVIEW=1 — noindex, and analytics disabled so it does not report into netscli.com's numbers.

Production is unaffected: netscli.com is served from GitHub Pages via pages.yml, which deploys from main.

@fstubner
fstubner merged commit 40dde38 into main Oct 7, 2026
21 checks passed
@fstubner
fstubner deleted the fix/release-pipeline branch October 7, 2026 23:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant