Compare commits

..
Author SHA1 Message Date
jaap-jan ad1a129159 CI: publish the desktop nightly with curl, since vpk's Gitea client cannot read the upgraded forge's replies
ci / build and test (push) Successful in 3m35s
ci / android head (push) Successful in 3m43s
ci / api image (push) Successful in 7s
ci / desktop nightly (push) Successful in 1m25s
Moving vpk to 1.2.161 was not enough: it got further and failed the same way, one frame later.
Both versions use a generated Gitea SDK that gets null back from 'create release' on the forge's
28.0.0, although the release is created, so the step threw before uploading anything and left an
empty draft behind on every push since 2 October.

The upload is now the android job's curl pattern. The release is created as a draft prerelease,
the three packages go up, then releases.win-nightly.json, which vpk pack already wrote and which is
exactly the index vpk upload would have generated, and only then is it published. The read-back
check, the RPM and the Linux manifest after it are unchanged.
2026-10-03 19:56:53 +02:00
jaap-jan e240c0cf26 Tests: prove the device-key probe runs off the caller's thread by blocking it, not by comparing thread ids
ci / build and test (push) Successful in 3m25s
ci / android head (push) Successful in 3m55s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 23s
TheChoiceIsMadeOnce_OffTheCallersThread failed on CI with the code doing the right thing. It
compared the probe's managed thread id with the test's, but the test runs on a pool thread, and that
same thread is free to pick up the queued probe as soon as the test awaits.

The probe now waits on a release the test only gives once construction has returned. A probe run on
the constructing thread could never be released, and the test fails after ten seconds instead of
passing by luck. Twenty runs pass; an inline probe fails it.
2026-10-03 19:50:25 +02:00
2 changed files with 64 additions and 29 deletions

No files matched your search

+48 -24
View File
@@ -1221,27 +1221,55 @@ jobs:
fi
curl -fsS -X DELETE -H "$auth" "$api/tags/$TAG" >/dev/null 2>&1 || true
# vpk rather than curl, unlike the android job above, and the difference is what is being
# uploaded. An APK is one file the client is told about by a manifest this repository writes; a
# Velopack release is a set of files plus an index whose format is Velopack's own. Writing that
# index by hand would be reimplementing the tool that is already here.
# curl, like the android job above, and no longer `vpk upload gitea`. vpk's Gitea client is a
# generated SDK that cannot read the forge's answer to "create a release" since its upgrade to
# 28.0.0: the release is created and the client gets null back, so both 1.2.0 and 1.2.161 threw a
# NullReferenceException before uploading anything, leaving an empty draft behind on every push.
# Nothing here writes Velopack's index by hand: `vpk pack` already wrote releases.win-nightly.json
# beside the packages, and with no earlier release downloaded into that directory it is exactly
# the index `vpk upload` would have generated from it.
#
# --pre is load-bearing twice over. It keeps this out of the release channel, which refuses
# A draft first, published only once everything is on it, which is the order vpk used too: the
# client reads the index and then fetches what it names, and a published release missing a
# package is a few seconds in which every desktop is told to download something that is not
# there. The index goes up last for the same reason.
#
# prerelease is load-bearing twice over. It keeps this out of the release channel, which refuses
# prereleases — and it keeps it out of `releases/latest`, which is what the *phone's* release
# channel reads: a desktop nightly published as a stable release would become the newest release
# in this repository and every phone on the release channel would start failing its update check
# against a release carrying no android manifest.
dotnet vpk upload gitea \
--skip-updates \
--repoUrl "$FORGE/$REPO" \
--token "$TOKEN" \
--outputDir "$RUNNER_TEMP/nightly-releases" \
--channel win-nightly \
--tag "$TAG" \
--releaseName "Nightly desktop $VERSION" \
--targetCommitish "$GITHUB_SHA" \
--pre \
--publish
releases="$RUNNER_TEMP/nightly-releases"
created="$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' \
-d "$(printf '{"tag_name":"%s","target_commitish":"%s","name":"Nightly desktop %s","draft":true,"prerelease":true,"body":"Built from %s by CI. Installs beside the release build, never over it."}' \
"$TAG" "$GITHUB_SHA" "$VERSION" "$GITHUB_SHA")" \
"$api/releases")"
draft="$(printf '%s' "$created" | release_id)"
if [ -z "$draft" ]; then
echo "Gitea accepted the release call and returned no id:" >&2
printf '%s\n' "$created" >&2
exit 1
fi
for file in "$releases"/*-full.nupkg "$releases"/*-Portable.zip "$releases"/*-Setup.exe \
"$releases"/releases.win-nightly.json; do
if [ ! -f "$file" ]; then
echo "vpk pack did not leave $file behind:" >&2
ls -la "$releases" >&2
exit 1
fi
name="$(basename "$file")"
echo "Uploading $name ($(wc -c < "$file") bytes)"
curl -fsS -X POST -H "$auth" \
-F "attachment=@$file" \
"$api/releases/$draft/assets?name=$name" >/dev/null
done
curl -fsS -X PATCH -H "$auth" -H 'Content-Type: application/json' \
-d '{"draft":false}' \
"$api/releases/$draft" >/dev/null
# Asked for back rather than assumed, and the android job's history is why: it once created a
# release, uploaded nothing, and reported success for every upload it never made. A nightly
@@ -1260,20 +1288,16 @@ jobs:
fi
done
# ◆ THE RPM GOES ONTO THE RELEASE vpk JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
# ◆ THE RPM GOES ONTO THE RELEASE JUST MADE, RATHER THAN ONTO ONE OF ITS OWN.
#
# One rolling release carrying both heads, because they are one nightly: the same commit,
# the same version, and one page a person looks at to find out what is on main. A second tag
# would double the delete-and-recreate dance this step already does, and would let the two
# drift apart on a run where one of them failed.
#
# It has to happen here, after `vpk upload`, and not in a step before it. vpk deletes and
# recreates this release, so an RPM uploaded first would be attached to something that no
# longer exists by the time the job ends — silently, because the delete succeeds.
#
# curl rather than vpk, unlike the assets above, for the reason the android job gives about
# its APK: this is one file the release simply carries. There is no index to write and no
# format that belongs to a tool.
# It has to happen here, after the Windows upload, and not in a step before it. This step
# deletes and recreates the release, so an RPM uploaded first would be attached to something
# that no longer exists by the time the job ends — silently, because the delete succeeds.
release="$(printf '%s' "$published" | release_id)"
if [ -z "$release" ]; then
echo "The release was published and its id could not be read back:" >&2
@@ -15,28 +15,39 @@ public sealed class ProbedDeviceKeyStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <remarks>
/// "Off the caller's thread" is proved by blocking, not by comparing thread ids. The probe waits for a
/// release the test only gives once construction has returned, so a probe run on the thread that
/// constructed the store could never be released and times out instead. Thread ids proved nothing: the
/// test itself runs on a pool thread, and that same thread is free to pick up the queued probe the
/// moment the test awaits, which is what failed on CI with the code doing exactly the right thing.
/// </remarks>
[Fact]
public async Task TheChoiceIsMadeOnce_OffTheCallersThread()
{
var asking = Environment.CurrentManagedThreadId;
var probes = 0;
var probedOn = 0;
var releasedInTime = false;
using var release = new ManualResetEventSlim();
var inner = Substitute.For<IDeviceKeyStore>();
var store = new ProbedDeviceKeyStore(() =>
{
Interlocked.Increment(ref probes);
probedOn = Environment.CurrentManagedThreadId;
releasedInTime = release.Wait(TimeSpan.FromSeconds(10));
return inner;
});
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
var available = store.IsAvailableAsync(Token);
available.IsCompleted.ShouldBeFalse("nothing has released the probe yet");
release.Set();
(await available).ShouldBeTrue();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
await store.TryLoadAsync(Token);
releasedInTime.ShouldBeTrue("the probe ran on the thread that asked, so nothing could release it");
probes.ShouldBe(1);
probedOn.ShouldNotBe(asking);
// Availability is the choice, not a second probe through the store it chose.
await inner.DidNotReceive().IsAvailableAsync(Arg.Any<CancellationToken>());