Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -247,30 +247,60 @@ data class GhReleaseAsset(
@SerialName("browser_download_url") val downloadUrl: String? = null,
)

/** A successful CI run, as the canary screen renders it. */
data class CanaryBuild(
val id: Long,
/**
* The build this is, and the key the installer selects by.
*
* CI tags every canary `canary-<versionCode>`, so the number is in the tag and needs no second
* request. It is what [FrameworkRelease.versionCode] holds for the same release, which is how a
* row here can hand the installer a build rather than a URL.
*/
val versionCode: Long,
/**
* A closed issue, as GitHub's issue list reports it.
*
* **This is how the canary screen knows what got fixed, and it has to be.** A commit message only
* names an issue when somebody wrote `Fixes #816` into it; this repository's issues are usually
* linked through the web UI's *Development* panel instead, which closes them on merge and writes
* nothing into the history at all. The link itself is only readable through GraphQL —
* `PullRequest.closingIssuesReferences` — and GraphQL answers 403 to an anonymous caller, which
* this app is by design. So what is asked instead is the question REST will answer without an
* account: which issues closed, and when.
*/
data class ClosedIssue(
val number: Int,
val title: String,
val branch: String,
val shortSha: String,
val epochSeconds: Long,
val closedAtEpoch: Long,
val htmlUrl: String?,
val artifacts: List<CanaryArtifact>,
)

@Serializable
data class GhIssue(
val number: Int,
val title: String = "",
@SerialName("closed_at") val closedAt: String? = null,
/**
* Why it closed: `completed`, `not_planned` or `duplicate`.
*
* Only the first is a fix. Counting the others would tell a reader that eleven issues were
* dealt with since their build when five of them were triage.
*/
@SerialName("state_reason") val stateReason: String? = null,
@SerialName("html_url") val htmlUrl: String? = null,
) {
/**
* Whether this is really a pull request.
*
* The issues endpoint returns both — a pull request *is* an issue to GitHub — and in one page
* of this repository's closed items more than half were pull requests. Told apart by the URL
* rather than by the presence of the `pull_request` object, which would mean decoding a nested
* payload none of whose fields are wanted.
*/
val isPullRequest: Boolean
get() = htmlUrl?.contains("/pull/") == true
}

/**
* A published build of the framework, canary or stable, with the zip to flash.
*
* One type for both channels because the install path is identical — the difference is only which
* of them a given reader is allowed to be offered.
*
* One type for the canary list as well, which used to read the same endpoint through a shape of its
* own. Two models over one response meant the canary page fetched what the update page was already
* holding, and could say nothing about a build that this type does not carry — no notes, no commit,
* and so no way to mark the one that is running.
*/
data class FrameworkRelease(
val tag: String,
Expand Down Expand Up @@ -302,6 +332,15 @@ data class FrameworkRelease(
/** The one to offer by default when nothing has been chosen. */
val defaultZip: CanaryArtifact?
get() = zips.firstOrNull { it.variant == ZipVariant.Release } ?: zips.firstOrNull()

/**
* The commit, abbreviated the way git abbreviates it, or null when the release names a branch.
*
* Seven characters because that is what `git rev-parse --short` gives on a repository this
* size, and what the commit rail already prints — the two are read side by side.
*/
val shortSha: String?
get() = commit?.take(7)
}

/**
Expand All @@ -321,7 +360,6 @@ data class CanaryArtifact(
val id: Long,
val name: String,
val sizeInBytes: Long,
val expired: Boolean,
val downloadUrl: String?,
) {
val variant: ZipVariant
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -705,57 +705,66 @@ class GitHubRepository(
.getOrNull()

/**
* The canary builds, newest first.
*
* These are **prereleases**, not Actions artifacts, and that is the whole point. GitHub gates an
* artifact download behind an account even for a public repository — `actions/artifacts/<id>/zip`
* answers 401 to an anonymous caller, while a release asset answers 206 — so sourcing canaries
* from artifacts would mean asking every would-be tester for an OAuth grant to work around a
* storage decision. CI attaches the same zips to a rolling `canary-<versionCode>` prerelease,
* and this reads that, so nobody signs in to anything.
*
* Filtered to the canary tag rather than taking every prerelease: a hand-cut release candidate
* is also a prerelease, and it is not a nightly.
* The issues that have been closed as done, newest first.
*
* **Asked of the issue tracker rather than derived from the commits, and that is not a
* shortcut.** An issue linked through GitHub's *Development* panel — the usual way here — is
* closed by the merge itself and leaves no trace in any commit message, so reading the history
* would report only the minority that happened to be written up as `Fixes #816`. The link that
* did the closing lives in `PullRequest.closingIssuesReferences`, which exists only in GraphQL,
* and GraphQL answers 403 without an account. This endpoint answers the neighbouring question
* anonymously, and the answer is the one worth showing: not which commit closed what, but what
* has been fixed since the reader's build.
*
* Closed is not fixed: `not_planned` and `duplicate` are also closures, and were a fifth of one
* page here. Only `completed` is counted.
*
* One page, unpaginated. A hundred items reaches back several weeks on this repository, which
* covers the span between any two builds a reader could be choosing between; older than that
* and the number stops mattering because the reader is being told to update, not to test.
*/
suspend fun canaryBuilds(freshness: Freshness = Freshness.Revalidate): List<CanaryBuild> =
suspend fun closedIssues(freshness: Freshness = Freshness.Revalidate): List<ClosedIssue> =
withContext(Dispatchers.IO) {
val body = releaseListJson(freshness) ?: return@withContext emptyList()

runCatching { json.decodeFromString<List<GhRelease>>(body) }
.onFailure { e -> logE("update: canary release list unreadable", e) }
val url = "$API/$REPO/issues?state=closed&per_page=100&sort=updated&direction=desc"
val body =
runCatching { get(url, freshness) }
.onFailure { e -> logW("canary: closed issue list unavailable", e) }
.getOrNull() ?: return@withContext emptyList()

runCatching { json.decodeFromString<List<GhIssue>>(body) }
.onFailure { e -> logE("canary: closed issue list unreadable", e) }
.getOrDefault(emptyList())
.filter { it.prerelease && it.tagName.startsWith(CANARY_TAG_PREFIX) }
.take(CANARY_KEEP)
.map { release ->
CanaryBuild(
id = release.id,
versionCode = release.versionCode() ?: 0,
title = release.name ?: release.tagName,
branch = release.tagName,
shortSha = release.targetCommitish.take(7),
epochSeconds = parseIso8601(release.publishedAt.orEmpty()),
htmlUrl = release.htmlUrl,
artifacts =
release.assets.map {
CanaryArtifact(
id = it.id,
name = it.name,
sizeInBytes = it.size,
expired = false,
downloadUrl = it.downloadUrl,
)
},
.filter { !it.isPullRequest && it.stateReason == "completed" }
.mapNotNull { issue ->
val closed = parseIso8601(issue.closedAt ?: return@mapNotNull null)
ClosedIssue(
number = issue.number,
title = issue.title,
closedAtEpoch = closed.takeIf { it > 0 } ?: return@mapNotNull null,
htmlUrl = issue.htmlUrl,
)
}
.sortedByDescending { it.closedAtEpoch }
}

/**
* Every published build, both channels, newest first.
*
* One fetch for both because they come from the same endpoint, and because deciding which
* channel a reader is on needs to see both: a canary that has aged out of the rolling five is
* still recognisable as a canary by being *newer than the newest stable release*, and that
* comparison is impossible with only one of the two lists in hand.
* The canaries here are **prereleases**, not Actions artifacts, and that is the whole point.
* GitHub gates an artifact download behind an account even for a public repository —
* `actions/artifacts/<id>/zip` answers 401 to an anonymous caller, while a release asset answers
* 206 — so sourcing canaries from artifacts would mean asking every would-be tester for an OAuth
* grant to work around a storage decision. CI attaches the same zips to a rolling
* `canary-<versionCode>` prerelease, and this reads those, so nobody signs in to anything.
*
* A canary is recognised by its tag rather than by being a prerelease: a hand-cut release
* candidate is also a prerelease, and it is not a nightly.
*
* One fetch for both channels because they come from the same endpoint, because deciding which
* channel a reader is on needs to see both — a canary that has aged out of the rolling five is
* still recognisable by being *newer than the newest stable release*, and that comparison is
* impossible with only one of the two lists in hand — and because the canary list is this same
* answer filtered, not a second question.
*/
suspend fun frameworkReleases(freshness: Freshness = Freshness.Revalidate):
List<FrameworkRelease> =
Expand Down Expand Up @@ -788,7 +797,6 @@ class GitHubRepository(
id = it.id,
name = it.name,
sizeInBytes = it.size,
expired = false,
downloadUrl = it.downloadUrl,
)
},
Expand Down Expand Up @@ -945,7 +953,14 @@ class GitHubRepository(

/** CI keeps five; a few extra are fetched so a stable release among them costs nothing. */
private const val CANARY_FETCH = 12
private const val CANARY_KEEP = 5

/**
* How many canaries CI keeps, which the canary screen states as reassurance.
*
* Read from here rather than written into the sentence, so the promise the screen makes
* and the number the workflow prunes to cannot drift apart silently.
*/
const val CANARY_KEEP = 5

private const val API = "https://api.github.com/repos"
private const val API_ROOT = "https://api.github.com"
Expand Down Expand Up @@ -982,6 +997,7 @@ class GitHubRepository(

private val PR_SUFFIX = Regex("""\(#(\d+)\)\s*$""")


private val LAST_PAGE = Regex("""[?&]page=(\d+)>;\s*rel="last"""")

private val CO_AUTHOR =
Expand Down
Loading
Loading