Skip to content

fix(release): close the residual native release admission gaps - #122

Merged
abrichr merged 1 commit into
mainfrom
fix/native-release-guard-residuals
Aug 20, 2026
Merged

fix(release): close the residual native release admission gaps#122
abrichr merged 1 commit into
mainfrom
fix/native-release-guard-residuals

Conversation

@abrichr

@abrichr abrichr commented Aug 20, 2026

Copy link
Copy Markdown
Member

Follow-up to #115 (6f43a97). It closes the three residual gaps that PR
deliberately left for a separate change, plus the three adjacent items raised in
the same review.

1. Branch-name bypass of the version advance guard

native-version-guard.yml gated its only job on
startsWith(github.head_ref, 'native-version/v'). The head branch name is
author controlled and GitHub counts a skipped job as a satisfied required
status check, so a version bump from any other branch skipped the guard.

Severity, after confirming the stated mitigation. native-freshness.yml's
publish-native-tag job does re-validate the exact five-file transform against
the engine commit on every push to main, so a bogus bump cannot reach a
desktop-v* tag. The bypass therefore lands a bad commit on main and jams the
freshness lane; it does not produce a signed release. Medium, and cheap to
close.

The fix is not a blanket branch requirement. The five paths also carry
ordinary npm and Cargo dependency state. Dependabot is configured for both
ecosystems, and history shows non-version commits touching package.json
(#104, #96, #48). Refusing every unreserved branch would break all of them.

The job now always runs and decides on content:

  • base version == head version -> pass (dependency and feature work).
  • version moved -> require the reserved native-version/v* branch of this
    repository
    (a fork can mint any branch name), the current protected base,
    and the exact strict advance.

It also checks out the base, not the head, so a pull request cannot rewrite
the guard that judges it. The head is fetched through refs/pull/N/head and
verified against the event SHA.

Simulated against six pull-request shapes, running the step's exact logic:

case result
reserved branch, exact transform PASS - validated 5 advancing files
unreserved branch, exact transform REFUSED - was silently skipped before
fork claiming the reserved branch name REFUSED
reserved branch + smuggled package.json script REFUSED - not the deterministic output
reserved branch, version goes down REFUSED - does not advance base
Dependabot, no version change PASS - keeps native version

2. Fail-open monotonicity check

write_verified_release_index and write_verified_release_channel read
if existing is not None and existing.is_file():, so a passed-but-absent
--existing silently dropped the strict-advance comparison. The caller made it
reachable: the download step ended with || true.

A passed but missing --existing is now an error. The workflow decides from the
release's authenticated asset list rather than from the absence of a downloaded
file. Simulated with a stubbed gh:

release state before after
no prior index (first release) no --existing no --existing
prior index present --existing passed --existing passed
download fails check silently vanishes step fails
asset listing fails check silently vanishes step fails

3. Release-order comparison set filtered by mutable metadata

_published_native_releases admitted a release only when draft was false,
prerelease was true, and INSTALLER_RELEASE_MARKER appeared in the body. All
three are mutable, and this repository rewrites release notes itself with
gh release edit. A release that lost the marker dropped out of the set and a
lower tag became acceptable.

The comparison set is now the immutable desktop-v* Git tag namespace, read
with git ls-remote --tags. Verified against this repository's real 66-line
ls-remote output: 17 native tags parsed, desktop-v0.9.0 and
desktop-v0.14.0 refused against desktop-v0.15.0, desktop-v0.16.0 accepted.

Two consequences, both handled:

  • The check now allows a candidate equal to the highest tag. It has to: the
    desktop-v* tag exists before both call sites read it, and the tag write is
    idempotent. Equality is still monotonic, and both call sites separately pin
    the tag to an exact commit.
  • The tag namespace cannot observe draft state, which the old release filter
    refused as a side effect. Without that, re-running the release workflow for an
    already published tag would let gh release edit --draft return a live release
    to draft and --clobber its assets. publish-draft now refuses that
    explicitly, at the step that would do the damage.

select_latest_native_release still reads release objects. It answers a
different question - which published release the event refers to - and its
failure mode is already closed (a release that loses its marker makes
event_is_selected false, and the job refuses).

Also in this change

  • verify() symlink re-resolution. It proved each member regular and
    unlinked, then hashed (directory / name).read_bytes(), resolving the name a
    second time. It now hashes the validated Path objects, and every read in the
    standalone verifier opens with O_NOFOLLOW where the platform has it
    (Windows has no equivalent and keeps the explicit is_symlink check).
  • Argument injection through a ref. _git_bytes and the git diff call
    interpolated a caller ref into an argv element, so a ref beginning with -
    reached Git as an option. Every ref now resolves through
    git rev-parse --verify to a 40-character object id before any other Git
    call, and the diff argument list ends with --. Verified that
    --output=<path>, -h, and --upload-pack=/bin/false are all refused and
    write no file.
  • Duplicated asset contract. expected_asset_names in the standalone
    verifier restates expected_release_asset_names. The verifier ships beside the
    installers and must stay stdlib-only, so the duplication stays and a contract
    test fails when the two definitions drift apart.

Verification

  • uv sync --locked --extra dev
  • uv run pytest tests/ -q -> 998 passed, 6 skipped
  • uv run ruff check engine/ tests/ scripts/ -> clean
  • actionlint (runs shellcheck over every run: block) -> clean

No release is cut and nothing is published. Desktop still pins openadapt-flow
1.31.0, which RELEASES.md records as a non-releasable interim pin.

The native installer matrix in build.yml and the full Linux/macOS/Windows test
matrix run only on pushes to main, so the post-merge run needs a check too.

🤖 Generated with Claude Code

Three gaps that PR #115 deliberately deferred, plus three adjacent ones.

Branch-name bypass of the version advance guard. The guard gated its only
job on `startsWith(github.head_ref, 'native-version/v')`. The head branch
name is author controlled and GitHub counts a skipped job as a satisfied
required status check, so a version bump from any other branch skipped the
guard entirely. The job now always runs for the five paths.

It cannot simply demand the reserved branch, because Dependabot npm/cargo
updates and ordinary feature work edit the same files for dependency
reasons. It decides on content instead: when the pull request leaves the
native version alone it passes; when the pull request moves the version,
only the reserved `native-version/v*` branch of this repository may do it,
from the current protected base, as the exact strict advance. The job also
checks out the base rather than the head, so a pull request cannot rewrite
the guard that judges it.

Fail-open monotonicity check. `write_verified_release_index` and
`write_verified_release_channel` skipped the strict-advance comparison when
`--existing` named an absent file, and the release workflow made that
reachable by ending its download with `|| true`. A passed but missing
`--existing` is now an error, and the workflow decides from the release's
authenticated asset list instead of from the absence of a downloaded file.

Release-order comparison set filtered by mutable metadata. The set admitted
a release only when `draft` was false, `prerelease` was true, and the body
carried the installer marker. All three are mutable, and this repository
rewrites release notes itself, so a release that lost the marker dropped out
and a lower tag became acceptable. The comparison set is now the immutable
`desktop-v*` Git tag namespace. That set cannot observe draft state, which
the old set refused as a side effect, so `publish-draft` now refuses
explicitly to return an already public release to draft.

Also:

- `verify()` proved each member regular and unlinked, then hashed
  `(directory / name).read_bytes()`, which resolved the name again and
  followed a link created in between. It now hashes the validated path
  objects, and every read in the standalone verifier opens with `O_NOFOLLOW`
  where the platform has it.
- `_git_bytes` and the `git diff` call interpolated a caller ref into an
  argv element, so a ref beginning with `-` reached Git as an option. Every
  ref now resolves through `git rev-parse --verify` to a 40-character object
  id first, and the diff argument list ends with `--`.
- `expected_asset_names` in the standalone verifier duplicates
  `expected_release_asset_names`. The verifier ships beside the installers
  and may not import the module, so a new contract test fails when the two
  definitions drift apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abrichr
abrichr merged commit 1511075 into main Aug 20, 2026
17 checks passed
@abrichr
abrichr deleted the fix/native-release-guard-residuals branch August 20, 2026 00:18
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