Skip to content

release: 5.9.5 - #1830

Merged
Jaro-c merged 6 commits into
mainfrom
develop
Sep 20, 2026
Merged

Jaro-c merged 6 commits into
mainfrom
develop

Conversation

@Jaro-c

@Jaro-c Jaro-c commented Sep 20, 2026

Copy link
Copy Markdown
Member

Five commits since 5.9.4, one of them visible to a user.

What ships

cp of a source whose last component is . copies the directory's contents (#1795). podup cp payload/. SERVICE:/tmp put the files in /tmp/payload/ before; they land in /tmp now, the way docker cp and podman cp treat it.

Measured against podman into a destination directory that already exists, because a non-existent destination flattens on its own and would have confounded the reading:

Source podman podup before podup now
payload dest/payload/a.txt same same
payload/. dest/a.txt dest/payload/a.txt matches
payload/./ dest/a.txt dest/payload/a.txt matches
. from inside dest/a.txt dest/payload/a.txt matches

The cue is read with std::path::is_separator, so the backslash shapes fire on Windows and not on Unix, where a backslash is an ordinary filename character. A source that is not a directory is refused the way podman refuses it. The container to host direction is unchanged and still lands the contents under the source's basename.

What does not ship but mattered

Four CI fixes with nothing user-visible in them. Two are worth naming:

branch health read the newest completed run on a branch and could answer for a commit nobody tested; it now reads the run for the branch's head (#1824). That change then broke both callers, because it handed the pushed commit to whichever branch was being asked about, and the two callers are cross-branch by design (#1827). Together they took branch health (main) on a push to develop from exhausting 32 attempts and failing after 8m18s down to 7 seconds, and closed a loop where a red on one branch held the other red.

Deliberately left out

#1820)

Closes #1810.

## The defect

When the three windowed queries came back empty and the final unwindowed
query returned a run **inside** the limit, the step exited 1 and
reported the contradiction. The gate's question is whether a scheduled
run exists inside the limit, and that run answers it, so the failure was
wrong. It fires with nothing broken, for instance when a run completes
between two queries.

## What changed

That branch now emits one `::warning::` naming both facts, the three
empty windowed pages and the timestamp the unwindowed query returned,
and exits 0. The other two branches are untouched: an unwindowed answer
that is absent, or one that is older than the limit, keeps its
`::error::` and exit 1.

## How I checked it

Case G in `tests/shell/reusable-schedule-freshness.test.sh` now asserts
exit 0, a `::warning::` naming both facts, and no `::error::` in the
output; the call count it already asserted is unchanged, and every other
case is untouched.

I sabotaged the branch four ways and each one dropped exactly one
assertion, no more:

| Sabotage | Assertion that fell |
|---|---|
| `exit 0` back to `exit 1` | G passes with a warning (exit 0) |
| `::warning::` prefix removed | G emits a warning naming both facts |
| `${latest}` dropped from the message | G emits a warning naming both
facts |
| an extra `::error::` added to the branch | G does not emit an error |

Gates, all rc=0: the test file (53 passed, 0 failed); the full
`test-command` line from `lint-shell.yml` (15 files, 0 failed);
`shellcheck` on the test; `actionlint` on the reusable.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…1821)

Closes #1812.

## The defect

Three tests pointed at `https://127.0.0.1:1` and asserted the connection
was refused. They inherited `ureq`'s default proxy handling, so with
`HTTPS_PROXY` set and no matching `NO_PROXY` the request went to the
proxy and the error was a different one. Port 1 being free was a second
assumption about the host.

## What changed

A test-only constructor beside `with_bases` builds the agent with proxy
handling disabled, and the base points at a port the test allocated from
the OS and released, so the refusal comes from a socket the test chose.
No new dependency, no change to `Cargo.lock`, and the public API is
unchanged. The assertions still anchor on the existing
`names_a_refused_connection` helper.

## How I checked it

**The obvious gate is worthless and I want that on the record.** Running
the suite with `HTTPS_PROXY=http://127.0.0.1:9` passes whether or not
proxy handling is disabled, because a refused proxy produces the same
`Connection refused` the tests assert. It proves nothing.

So I ran a local listener that *accepts* the connection and closes it,
and pointed `HTTPS_PROXY` at that:

- with proxy handling disabled: 13 passed, 0 failed;
- with that one line reverted: `FAILED. 10 passed; 3 failed`, and the
three are exactly `latest_version_maps_transport_error`,
`fetch_maps_transport_error` and `https_base_passes_the_scheme_check`,
each reporting `Connection reset by peer (os error 104)` instead of the
refusal.

I also checked the test-only agent does not blind the shipping one:
dropping `https_only(true)` from `new()` alone still fails
`plaintext_base_is_refused_for_being_plaintext`, which builds through
`new()`.

Gates, all rc=0: `cargo test --locked --lib --all-features update::` (81
passed, 0 failed), `cargo fmt --all --check`, `cargo clippy --locked
--all-targets --all-features -- -D warnings`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…1822)

Closes #1795.

## The defect

`podup cp payload/. svc:/tmp` should copy the CONTENTS of `payload` into
`/tmp`, the way `docker cp` and `podman cp` treat a source whose last
component is `.`. It copied the directory itself, so the files landed in
`/tmp/payload/`.

`pack_path` named the archive's top entry with `src.file_name()`, and
Rust's `Path` drops a trailing `.`, so
`Path::new("payload/.").file_name()` is `Some("payload")` and nothing
between the CLI and the packer ever saw the cue.

## What changed

The cue is detected on the source as written, before it becomes a
`Path`, and threaded to the packer, which puts the directory's children
at the top of the archive with no wrapper entry. A source that is not a
directory is refused with the "not a directory" shape podman returns.
The contents archive has no wrapper, so the confirmation routes through
`tree_landed`, which asks about each entry, rather than the single-entry
path.

## How I measured the rule

Against podman on Linux, into a destination directory that **already
exists** (a non-existent destination flattens on its own and would have
confounded the reading):

| Source | podman | podup before | podup now |
|---|---|---|---|
| `payload` | `dest/payload/a.txt` | same | same |
| `payload/` | `dest/payload/a.txt` | same | same |
| `payload/.` | `dest/a.txt` | `dest/payload/a.txt` | matches |
| `payload/./` | `dest/a.txt` | `dest/payload/a.txt` | matches |
| `payload/.//` | `dest/a.txt` | `dest/payload/a.txt` | matches |
| `.` from inside | `dest/a.txt` | `dest/payload/a.txt` | matches |
| `./` from inside | `dest/a.txt` | `dest/payload/a.txt` | matches |

A single file with the cue: podman answers `not a directory` and exits
125; podup returns `cp: not a directory: <path>`.

The cue is read with `std::path::is_separator`, the same idiom
`has_trailing_separator` already uses, so the backslash shapes fire on
Windows and not on Unix, where a backslash is an ordinary filename
character. **The `#[cfg(windows)]` shapes did not run on this host** and
what `podman cp` does with `payload\.` on Windows is not measured; the
rule is written as podup's own, derived from the platform separator, and
claims no parity there.

## Controls

Both sabotages drop exactly the shapes they name:

| Sabotage | Assertions that fell |
|---|---|
| back to the literal `ends_with("/.")` | the four new shapes: `.`,
`./`, `payload/./`, `payload/.//` |
| accept `..` as the cue | the two `..` shapes |

Gates, all rc=0: `cargo test --locked --lib --all-features engine::copy`
(123 passed), the live `cp_flags::` with `PODUP_REQUIRE_PODMAN=1` (8
passed, so no test skipped itself), `cargo fmt --all --check`, `cargo
clippy --locked --all-targets --all-features -- -D warnings`.

## What this does not close

The container-to-host direction has the same divergence and is
untouched: `cp svc:/path/. host_dir` still lands the contents under the
source's basename when the destination exists. Closing it needs the
archive post-processed or the cue detected in `cp_from_container`,
because libpod normalises the trailing `/.` away server side.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
… run (#1824)

Closes #1809.

## Two defects

**It could answer for a commit nobody tested.** The verdict was the
newest completed run on the branch, with cancelled runs passed over. Let
A pass, push B, cancel B's run by hand with nothing replacing it, and
the script reported A's success as the branch's health.

**The two callers fed each other.** A real failure on `main` made the
next otherwise healthy `develop` run fail `health-main`; a repaired
`main` then failed `health-develop` against that `develop` result, and
they held each other red. Clearing it on 2026-09-20 took three reruns in
a chain: the push run on `develop`, then the release pull request's run,
then the push run on `main`.

## What changed

The verdict is the run whose `head_sha` is the branch's current head:
`GITHUB_SHA` on a push, and one extra API read to learn the head on
`schedule` and `pull_request`. A run that has not finished is waited
for, 15 seconds between attempts and at most 32 attempts, inside the
job's ten-minute bound. A `cancelled` run for that head is red, because
nothing newer can answer for the commit, and the pass-over disappears
with the newest-run logic it belonged to. Same shape the three
distribution channels shipped for their own suite-on-main check.

A failed `gh api` counts as no answer for that attempt and the loop
keeps polling, with a `::warning::` naming the failure. Under `set -euo
pipefail` an unguarded call would have aborted the script on the first
rate limit, which is a retry loop that runs exactly once.

## Controls

Every sabotage drops exactly the assertions named for the behaviour:

| Sabotage | Assertions that fell |
|---|---|
| `::error::` moved to stderr | `split streams: ::error:: on stdout` |
| `::warning::` moved to stderr | `split streams: ::warning:: on
stdout`, `::warning:: NOT on stderr` |
| a failed `gh api` aborts instead of polling on | the four `transient
gh failure` assertions |

The stream assertions capture stdout and stderr into separate files; the
rest of the suite runs the script with `2>&1`, which merges them and
cannot tell the two apart.

## What I fixed on review before this landed

The first pass had the design right and four defects the gates did not
catch: three of four files lost their final newline (`.editorconfig`
sets `insert_final_newline = true` and CI checks it); all six
`::error::` lines moved to stderr, against 55 sites in `develop` that
all use stdout; an untouched comment about an unrelated 2026-09-06
incident was reflowed into `reason anybody knew was it that somebody
went looking`; and the poll loop aborted on a failed `gh api`. All four
are fixed here.

Gates, all rc=0: `bash tests/shell/branch-health-conclusion.test.sh` (51
passed), the full `test-command` line from `lint-shell.yml` (15 files, 0
failures), `cargo test --locked --test 'workflow_*'` (31 passed),
`shellcheck` on the script and its test, `actionlint` on the reusable.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Closes #1826. Introduced by #1824, which I merged an hour earlier.
`develop` is red on it.

## The defect

The reusable set `HEAD_SHA` to the pushed commit on **any** push, and
the script then looked for a run of the watched workflow on the watched
branch whose `head_sha` was that commit.

The two callers in `ci.yml` are cross-branch by design: `health-main`
runs on every push that is not to `main`, and `health-develop` on every
push that is not to `develop`. So a push to `develop` made `health-main`
ask about `main` using `develop`'s commit. No run on `main` ever carries
it.

Measured on run 35524950565, the push run for `b75f507` on `develop`:

    branch health (main) failed after 8m18s
no ci.yml run for commit b75f507 on main finished within 32 attempts
(each 15s)

Symmetric, so a push to `main` fails `health-develop` the same way. And
not cosmetic: it makes `ci.yml` conclude `failure` on the branch, and
the next pull request reads that conclusion and fails in eight seconds.
That is how #1823 got blocked.

## The fix

`HEAD_SHA` carries the pushed commit only when the push was to the
branch being checked; otherwise it stays empty and the script resolves
the branch head through the API, the path `pull_request` and `schedule`
already use.

## Why the existing tests could not catch it

The 51 shell assertions exercise the script with `HEAD_SHA` set and with
it empty. That is the script's own contract and it was never wrong. The
defect was in the **workflow expression that decides `HEAD_SHA`**, and
nothing tested that. While reviewing #1824 I sabotaged the script six
ways and every sabotage bit; none of them could touch a line of YAML.

The new test reads that one line, parses the expression out of it and
compares it whole. Anchored to the `HEAD_SHA:` line, because a `contains
"inputs.branch"` check would be satisfied by the comment above it or by
the `BRANCH:` env line below. Compared by equality, because a shape that
merely names the right identifiers is not the right shape.

It also carries a test of the parser itself: a planted buggy step must
not parse to the expected expression, and a planted correct one must.
Without that, a broken parser would make the real assertion pass on
anything.

Reverting the expression to the buggy one drops exactly
`head_sha_expression_compares_pushed_branch_with_branch_under_test` and
nothing else.

Gates, all rc=0: `cargo test --locked --test 'workflow_*'` (33 passed),
`bash tests/shell/branch-health-conclusion.test.sh` (51 passed), `cargo
fmt --all --check`, `actionlint` on both the reusable and `ci.yml`.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Bumps the three seals: `Cargo.toml`, `Cargo.lock` and
`debian/changelog`. Verified they agree, that `cargo metadata --locked`
does not move the lock, and that `dpkg-parsechangelog` reads `Version:
5.9.5`.

## What 5.9.5 carries

Five commits since 5.9.4, and only one of them is visible to a user, so
the changelog has one entry:

| Commit | User-facing |
|---|---|
| `ea33e38` #1795 `cp` of a source ending in `/.` copies the contents |
**yes** |
| `53e9c51` #1812 updater transport tests no longer depend on the
machine | no, a `#[cfg(test)]` constructor |
| `8c0ce68` #1810 freshness passes when it has proof of freshness | no,
CI |
| `b75f507` #1824 branch health reads the run for the branch's head |
no, CI |
| `5eddf57` #1827 branch health gates `HEAD_SHA` to the branch under
test | no, CI |

The last two are worth naming even though they ship nothing: together
they took `branch health (main)` on a push to `develop` from exhausting
32 attempts and failing after 8m18s down to **7 seconds**, and they
closed a loop where a red on one branch held the other red.

## What is deliberately NOT in it

- **#1808**, the symlink target confirmation. It is right on Podman
5.7.0 and fails on Podman 6.1.2 by reporting a landed copy as failed,
which is the #1777 shape. PR #1823 is closed unmerged and the issue
carries the full state and the next step.
- **#1825**, the live pod userns test. Its control is proven (sabotaging
`pod_userns` makes it fail with the exact values) but it has an open
defect of its own: it asserts on the first line of the ID map, and
`auto:size=2048` can arrive split across two ranges. It stays in its
pull request.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
@Jaro-c
Jaro-c merged commit 83315af into main Sep 20, 2026
98 of 100 checks passed
@Jaro-c

Jaro-c commented Sep 20, 2026

Copy link
Copy Markdown
Member Author

Correction to this pull request's description, and to the commit message of #1822 which is now in main.

Both say the container-to-host direction still lands the contents under the source's basename, or "has the same divergence". That is false, and I did not measure it before writing it. I took it from the working notes of the change rather than from the daemon.

Measured on the published 5.9.5 linux-x86_64 binary against Podman 5.7.0, four cases, comparing podup with podman cp on the same source tree:

Case podup podman cp
a:/orig/. dst where dst exists contents in dst contents in dst
a:/orig dst where dst exists wrapped as dst/orig same
a:/orig/. dst where dst does not exist contents in dst contents in dst
a:/orig dst where dst does not exist contents in dst contents in dst

They agree in all four. The container-to-host direction handles the trailing /. correctly and always did; there was nothing to close there.

The 5.9.5 changelog entry is not affected: it says "The container to host direction is unchanged", which is accurate, since the change did not touch it.

What this costs: #1822 shipped with a "what this does not close" paragraph describing a defect that does not exist, and that paragraph is in main's history where it cannot be edited. Anyone reading that commit later will go looking for a divergence that is not there. This comment is the correction of record.

Jaro-c added a commit that referenced this pull request Sep 21, 2026
…t carries it (#1853)

Closes #1831. P0: this makes the 5.9.5 release run on `main` red and
fails both checks on every pull request opened since.

## The defect

The script asked for `workflows/${workflow}/runs?branch=${branch}`,
filtered by `head_sha`, sorted by `created_at` and took the newest. It
did not filter by event.

A release pull request goes from `develop` into `main`, so its head
branch is `develop` and its run appears under `?branch=develop` beside
the push run for the same commit. Being newer, it wins. For `develop`'s
head `20937cc`:

| Run | Event | Conclusion |
|---|---|---|
| #2539 | `pull_request` | failure |
| #2538 | `push` | **success** |

The script read #2539. That run is #1830, the release pull request, and
its `ci.yml` concluded failure **because this very job failed inside
it**. The gate read its own run, failed, made that run red, and every
later reader saw a red the gate created. #1809 was opened to close
exactly this loop in its other form.

## Why it is not cosmetic

On run 35538230895, the push run for `83315af` on `main` and the 5.9.5
release commit, `branch health (develop)` is the **only** failing job.
`main`'s history records the release as red, about the state of a branch
that was green, as reported by a run that branch did not trigger.

## The fix

Both the runs call and the jq filter select `event=push`. A branch's
health is the verdict of the run that branch triggered; a `pull_request`
run that merely carries the branch as its head is a different question.

Nothing else moved: the polling bound, the cancelled-is-red rule and the
`HEAD_SHA` gating from #1827 are unchanged. Three defects in a row have
come out of this file and widening the change is how a fourth arrives.

## Sabotage

Removing the filter from **both** places, because removing one leaves
the other enforcing it, drops four assertions:

```
FAIL  newer pull_request failure cannot shadow older push success for head
      (gate must not read a run the branch did not trigger): exit 0
FAIL  verdict line names run #60 (the push run, not the pull_request run #62)
FAIL  verdict line does NOT name run #62 (the pull_request run)
FAIL  pull_request run URL #62 must NOT appear
```

The suite also carries the assertion that stops the lazy fix: a **newer
push failure** over an older push success still fails, so this cannot
become "prefer whatever succeeded".

Gates, all rc=0: `bash tests/shell/branch-health-conclusion.test.sh` (59
passed), `cargo test --locked --test 'workflow_*'` (33 passed), `cargo
fmt --all --check`, `shellcheck` on the script and its test,
`actionlint` on the reusable.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant