diff --git a/README.md b/README.md index ec03d63..55d9592 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ else's review — is behind a checkbox that is off by default. See ## Documentation +- [Handleiding](documentation/handleiding.md) — Dutch, for administrators and process designers: setting it up in the admin UI, no technical background needed - [Getting started](documentation/getting-started.md) — running the sandbox and its fixtures, and developing on it - [Plugin documentation](documentation/plugin.md) — the actions, their properties and what they return - [Release notes](documentation/release-notes.md) — version history diff --git a/backend/plugin/plugin.properties b/backend/plugin/plugin.properties index 7bf70b9..d31066b 100644 --- a/backend/plugin/plugin.properties +++ b/backend/plugin/plugin.properties @@ -1,3 +1,3 @@ pluginGroupId=com.ritense.valtimoplugins pluginArtifactId=github -pluginVersion=1.0.0 +pluginVersion=1.0.1 diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/client/GitHubClient.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/client/GitHubClient.kt index 3a89b7d..72ea8d8 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/client/GitHubClient.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/client/GitHubClient.kt @@ -151,10 +151,19 @@ class GitHubClient( } pages++ + next = nextLink(response.headers) if (limit != null && items.size() >= limit) { - return PagedResult(trim(items, limit), truncated = true, totalCount = totalCount) + return PagedResult( + items = trim(items, limit), + // Only truncated when something was actually left behind: either this + // page held more than was asked for, or GitHub offered another one. A + // list that happens to be exactly [limit] long was not cut short, and + // saying it was sends a process down the "there is more to do" branch + // on every run. + truncated = items.size() > limit || next != null, + totalCount = totalCount, + ) } - next = nextLink(response.headers) } return PagedResult( @@ -164,6 +173,43 @@ class GitHubClient( ) } + /** + * Reads an endpoint that answers with plain text, following the redirect to storage that + * GitHub serves an Actions job log through. + * + * The redirect is followed by hand rather than by the http client, because the target is + * a pre-signed URL on a host that is not GitHub — Azure blob storage — and a client + * configured to follow redirects would carry the `Authorization` header there with it. + * The signature is what authorises that second call; the token would only be a GitHub + * credential handed to a third party that never needed it. + * + * Null when GitHub answered with neither a body nor somewhere to fetch one from, which + * is what an expired log looks like. Callers are expected to say so rather than pass an + * empty log off as a log that was read. + */ + fun getText( + connection: GitHubConnectionProperties, + path: String, + ): String? { + val response = exchange(connection, HttpMethod.GET, uri(connection, path), null) + val location = response.headers.location + if (response.status in REDIRECTED && location != null) { + return restClientBuilder + .clone() + .build() + .get() + .uri(location) + .headers { it.set(HttpHeaders.USER_AGENT, USER_AGENT) } + .retrieve() + .body(String::class.java) + ?.takeIf { it.isNotEmpty() } + } + // The raw bytes rather than the parsed body: a log is text, and a log whose first + // line happens to parse as JSON — a bare timestamp does — would come back through + // the tree as that one value with the rest of the log dropped. + return String(response.raw, Charsets.UTF_8).takeIf { it.isNotEmpty() } + } + /** * Runs a GraphQL document. * @@ -233,10 +279,10 @@ class GitHubClient( status = response.statusCode.value(), ) } - Response(parsed, response.headers) + Response(response.statusCode.value(), parsed, raw, response.headers) }, false) - return entity ?: Response(NullNode.instance, HttpHeaders.EMPTY) + return entity ?: Response(0, NullNode.instance, ByteArray(0), HttpHeaders.EMPTY) } /** @@ -308,8 +354,14 @@ class GitHubClient( objectMapper.createArrayNode().apply { (0 until limit).forEach { add(items.get(it)) } } } - private data class Response( + /** + * Not a data class: [raw] holds the bytes [body] was parsed from, and an array makes + * generated equality mean something other than it reads as. + */ + private class Response( + val status: Int, val body: JsonNode, + val raw: ByteArray, val headers: HttpHeaders, ) @@ -338,5 +390,8 @@ class GitHubClient( private const val API_VERSION = "2022-11-28" private const val USER_AGENT = "valtimo-github-plugin" private const val MAX_ERROR_LENGTH = 1000 + + /** The statuses [getText] treats as "the body is somewhere else". */ + private val REDIRECTED = setOf(301, 302, 303, 307, 308) } } diff --git a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperations.kt b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperations.kt index e153d78..52594bf 100644 --- a/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperations.kt +++ b/backend/plugin/src/main/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperations.kt @@ -778,9 +778,14 @@ class GitHubOperations( * The log of one job, tail-first. * * GitHub answers this endpoint with a redirect to blob storage and plain text rather - * than JSON, so the body arrives as a string. [maxLines] keeps the tail — a failing job - * says why it failed at the end, and the beginning is several thousand lines of setup - * that would fill a process variable with nothing. + * than JSON, which is why it goes through [GitHubClient.getText] rather than the JSON + * calls every other action uses. [maxLines] keeps the tail — a failing job says why it + * failed at the end, and the beginning is several thousand lines of setup that would + * fill a process variable with nothing. + * + * A log GitHub would not hand over — an expired run, a job still starting — reports + * `available` false with the reason, rather than an empty log that a process would read + * as a job that said nothing. */ fun getJobLogs( connection: GitHubConnectionProperties, @@ -788,18 +793,14 @@ class GitHubOperations( jobId: Long, maxLines: Int, ): ObjectNode { - val response = - runCatching { client.get(connection, "/repos/$repository/actions/jobs/$jobId/logs") } + val text = + runCatching { client.getText(connection, "/repos/$repository/actions/jobs/$jobId/logs") } .getOrElse { failure -> logger.warn(failure) { "Could not read logs of job $jobId in $repository" } - return obj { - put("jobId", jobId) - put("available", false) - put("reason", failure.message) - } + return unavailable(jobId, failure.message) } + ?: return unavailable(jobId, "GitHub returned no log for job $jobId") - val text = if (response.isTextual) response.asText() else response.toString() val lines = text.lines() val tail = if (lines.size > maxLines) lines.takeLast(maxLines) else lines @@ -1110,6 +1111,16 @@ class GitHubOperations( // ─── helpers ──────────────────────────────────────────────────────────── + private fun unavailable( + jobId: Long, + reason: String?, + ): ObjectNode = + obj { + put("jobId", jobId) + put("available", false) + put("reason", reason) + } + private fun defaultBranch( connection: GitHubConnectionProperties, repository: RepositoryRef, diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/client/GitHubClientTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/client/GitHubClientTest.kt index 8b8507b..363f40d 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/client/GitHubClientTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/client/GitHubClientTest.kt @@ -95,6 +95,73 @@ class GitHubClientTest : BaseTest() { assertThat(result.truncated).isTrue() } + /** + * A list that happens to be exactly as long as the limit was not cut short. Reporting it + * as truncated sends a process down the "there is more to do" branch on every run. + */ + @Test + fun `should not call a list truncated when it ended exactly on the limit`() { + enqueue("""[{"number":1},{"number":2}]""") + + val result = client.getPaged(connection(), "/repos/o/r/issues", limit = 2) + + assertThat(result.items).hasSize(2) + assertThat(result.truncated).isFalse() + } + + @Test + fun `should call a list that ended on the limit truncated when github offered another page`() { + enqueuePage("""[{"number":1},{"number":2}]""", next = "/repos/o/r/issues?page=2") + + val result = client.getPaged(connection(), "/repos/o/r/issues", limit = 2) + + assertThat(result.items).hasSize(2) + assertThat(result.truncated).isTrue() + } + + /** + * An Actions job log is not JSON: GitHub answers 302 to a pre-signed blob URL. Following + * it is the only way to get the log at all — without it the body is empty and a caller + * cannot tell that from a job that logged nothing. + */ + @Test + fun `should follow the redirect github serves a job log through`() { + val blob = MockWebServer() + blob.start() + blob.enqueue(MockResponse().setHeader("Content-Type", "text/plain").setBody("setup\nBUILD FAILED")) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", blob.url("/log.txt").toString())) + + val text = client.getText(connection(), "/repos/o/r/actions/jobs/1/logs") + + assertThat(text).isEqualTo("setup\nBUILD FAILED") + blob.shutdown() + } + + /** + * The signature on the blob URL is what authorises the second call. Carrying the GitHub + * token to a host that is not GitHub would hand a credential to a third party that never + * needed it. + */ + @Test + fun `should not carry the github token to the storage host it is redirected to`() { + val blob = MockWebServer() + blob.start() + blob.enqueue(MockResponse().setHeader("Content-Type", "text/plain").setBody("log")) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", blob.url("/log.txt").toString())) + + client.getText(connection(), "/repos/o/r/actions/jobs/1/logs") + + assertThat(blob.takeRequest().getHeader("Authorization")).isNull() + blob.shutdown() + } + + @Test + fun `should report no log rather than an empty one when github offers nothing to fetch`() { + server.enqueue(MockResponse().setResponseCode(204)) + + assertThat(client.getText(connection(), "/repos/o/r/actions/jobs/1/logs")).isNull() + } + @Test fun `should unwrap a search response and keep what github said the total was`() { enqueue("""{"total_count":1337,"incomplete_results":false,"items":[{"number":7}]}""") diff --git a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperationsTest.kt b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperationsTest.kt index f026997..541223b 100644 --- a/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperationsTest.kt +++ b/backend/plugin/src/test/kotlin/com/ritense/valtimoplugins/github/service/GitHubOperationsTest.kt @@ -392,6 +392,47 @@ class GitHubOperationsTest : BaseTest() { ).isFalse() } + // ─── job logs ─────────────────────────────────────────────────────────── + + @Test + fun `should keep the tail of a job log, which is where a failing job says why`() { + whenever(client.getText(any(), eq("/repos/$repository/actions/jobs/7/logs"))) + .thenReturn("setup\ncompile\ntest\nBUILD FAILED") + + val result = operations.getJobLogs(connection, repository, 7, maxLines = 2) + + assertThat(result.path("available").asBoolean()).isTrue() + assertThat(result.path("log").asText()).isEqualTo("test\nBUILD FAILED") + assertThat(result.path("truncated").asBoolean()).isTrue() + assertThat(result.path("totalLines").asInt()).isEqualTo(4) + } + + /** + * An expired run has no log to hand over. Saying `available` false is the difference + * between a process reporting that it could not read the log and one reporting that the + * job failed silently. + */ + @Test + fun `should say a log was not available rather than hand back an empty one`() { + whenever(client.getText(any(), eq("/repos/$repository/actions/jobs/7/logs"))).thenReturn(null) + + val result = operations.getJobLogs(connection, repository, 7, maxLines = 200) + + assertThat(result.path("available").asBoolean()).isFalse() + assertThat(result.has("log")).isFalse() + } + + @Test + fun `should say why a log could not be read when github refused it`() { + whenever(client.getText(any(), eq("/repos/$repository/actions/jobs/7/logs"))) + .thenThrow(GitHubException("Not Found", status = 404)) + + val result = operations.getJobLogs(connection, repository, 7, maxLines = 200) + + assertThat(result.path("available").asBoolean()).isFalse() + assertThat(result.path("reason").asText()).contains("Not Found") + } + // ─── branches ─────────────────────────────────────────────────────────── @Test diff --git a/documentation/handleiding.md b/documentation/handleiding.md new file mode 100644 index 0000000..95b788f --- /dev/null +++ b/documentation/handleiding.md @@ -0,0 +1,167 @@ +# Handleiding + +Voor beheerders en procesontwerpers die GitHub vanuit een proces willen bedienen. Je hebt +hiervoor geen programmeerkennis nodig: alles in deze handleiding gebeurt in de +beheerinterface en in de procesmodelleur. Kennis van GitHub zelf — issues, pull requests, +branches — wordt wel verondersteld. + +Zoek je de precieze naam of het type van een instelling, of wat een actie exact teruggeeft, +dan staat dat in de [pluginreferentie](plugin.md). + +## Wat deze plugin doet + +De plugin laat een proces met GitHub praten: issues en pull requests lezen en aanmaken, +reviews afhandelen, CI-checks volgen, bestanden en branches beheren en projectborden +bijwerken. In totaal 33 acties, plus twee vrije acties voor alles wat daar niet in past. + +Daarmee kun je werk dat nu in scripts of met de hand gebeurt als proces modelleren: een +ticket oppakken, een branch maken, een pull request openen, de checks afwachten, de +reviewopmerkingen beantwoorden en mergen — met de volgorde en de beslissingen in het +procesdiagram in plaats van in een script. + +Twee dingen die de plugin **niet** doet: + +- **Hij kijkt niet zelf.** Er wordt niets gepolld en er komt niets uit zichzelf binnen. Wil + je dat een proces elke tien minuten de openstaande issues bekijkt, dan zeg je dat met een + timer in het procesdiagram, die vervolgens de actie *Issues opsommen* aanroept. +- **Hij ontvangt geen webhooks.** GitHub kan dit proces niet aanstoten; het proces vraagt + altijd zelf. + +## Voordat je begint + +| Wat je nodig hebt | Wie levert dat meestal | +| --- | --- | +| Een GitHub-token met precies de rechten die de processen nodig hebben | de beheerder van de GitHub-organisatie | +| De naam van de repository, in de vorm `eigenaar/repository` | de ontwikkelaars van het project | +| Bij GitHub Enterprise Server: het adres van de eigen GitHub-server | de beheerder van die server | + +## Stap 1 — De verbinding instellen + +Ga in de beheerinterface naar **Plugins** en kies **Plugin configureren**. Kies bij +*Kies je plugin* de tegel **GitHub**. Je krijgt dan een formulier met de onderstaande velden. +Bewaar met **Configuratie opslaan**. + +| Veld | Wat je invult | +| --- | --- | +| **Configuratienaam** | Een naam die jij herkent, bijvoorbeeld `GitHub – leesrechten`. Deze naam kies je later bij het koppelen aan een processtap. | +| **GitHub API-URL** | `https://api.github.com` voor github.com. Voor een eigen GitHub Enterprise Server is dit `https://jouw-host/api/v3`. | +| **Token** | Het GitHub-token. Lees eerst [Over het token](#over-het-token) hieronder. | +| **Standaard repository** | Optioneel: `eigenaar/repository`. Elke actie die zelf geen repository invult, werkt op deze. Laat leeg als de processen over meerdere repositories werken. | +| **GraphQL-URL** | Laat leeg. De plugin leidt hem af uit de API-URL, en dat klopt voor zowel github.com als Enterprise Server. | +| **Items per pagina** | Laat leeg. Bepaalt hoeveel items er per aanvraag bij GitHub worden opgehaald; 100 is het maximum dat GitHub toestaat. | +| **Maximum aantal pagina's per actie** | Hoeveel pagina's één lijstactie leest voordat hij stopt. Standaard 5, dus maximaal zo'n 500 items. Zie [Lijsten en onvolledige lijsten](#lijsten-en-onvolledige-lijsten). | + +### Over het token + +**Alles wat dit token mag, mag elk proces dat aan deze configuratie gekoppeld is.** De acties +controleren dat niet, en de vrije REST- en GraphQL-actie bereiken alles wat het token +bereikt. Geef een configuratie die alleen hoeft te lezen dus ook een token dat alleen kan +lezen. + +Moeten sommige processen lezen en andere schrijven, maak dan **twee configuraties** met twee +tokens in plaats van één token dat alles mag. In de procesmodelleur kiest elke stap zelf +welke configuratie hij gebruikt, dus dat kost verder niets. + +Let ook op **wie** het token is: GitHub staat niet toe dat een account zijn eigen pull request +goedkeurt. Laat je een proces pull requests aanmaken én reviewen, dan zijn dat twee accounts. +De actie *Ingelogde gebruiker ophalen* vertelt je wie er achter een token zit. + +## Stap 2 — Een actie aan een processtap koppelen + +Open het proces in de procesmodelleur en zet een **servicetaak** (service task) neer. Klik de +taak aan, kies **Proceskoppeling aanmaken**, kies de configuratie uit stap 1 en daarna de +actie die je wilt uitvoeren. + +Elke actie heeft twee velden die altijd terugkomen: + +| Veld | Wat je invult | +| --- | --- | +| **Repository** | Laat leeg om de standaard repository van de configuratie te gebruiken. Accepteert ook een procesvariabele, bijvoorbeeld `pv:targetRepository`, zodat één proces meerdere repositories kan bedienen. | +| **Resultaatvariabele** | De naam van de procesvariabele waarin het antwoord terechtkomt. Laat leeg om het antwoord niet te bewaren. | + +Een lege resultaatvariabele is een prima keuze bij een actie waar het om de handeling gaat en +niet om het antwoord — een geplaatste reactie, een toegekend label. Bij een actie die iets +ophaalt, vul je hem uiteraard wel in; anders heeft de stap niets opgeleverd. + +De overige velden verschillen per actie en hebben allemaal een toelichting in het formulier +zelf. + +## Welke acties er zijn + +| Groep | Acties | +| --- | --- | +| **Repository en gebruiker** | Repository ophalen · Repositories opsommen · Ingelogde gebruiker ophalen | +| **Issues** | Issues opsommen · Issue ophalen · Issues en pull requests zoeken · Issue aanmaken · Issue bijwerken · Reageren op issue of pull request | +| **Pull requests** | Pull requests opsommen · Pull request ophalen · Pull request aanmaken · Pull request bijwerken · Pull request klaarzetten of terug naar concept · Pull request mergen | +| **Review** | Pull request reviewen · Reviewers vragen · Reviewthreads ophalen · Antwoorden op reviewreactie · Reviewthread oplossen | +| **Labels** | Label aanmaken | +| **CI** | Checkstatus ophalen · Workflow-runs opsommen · Workflow-run ophalen · Joblogboek ophalen · Workflow opnieuw uitvoeren | +| **Bestanden en branches** | Bestandsinhoud ophalen · Bestand aanmaken of bijwerken · Branch aanmaken | +| **Projectborden** | Projectitems ophalen · Projectveld instellen | +| **Al het overige** | REST-aanvraag · GraphQL-query | + +De laatste twee zijn een achterdeur: die sturen je aanvraag ongewijzigd naar GitHub door en +bewaren het antwoord zoals het binnenkomt. Ze bestaan omdat GitHub meer kan dan in deze lijst +past. Kun je iets doen met een gewone actie, doe dat dan — die geeft een veel beter leesbaar +antwoord terug. + +## Het antwoord gebruiken + +Een actie die één ding ophaalt — een issue, een repository — zet de gegevens daarvan +rechtstreeks in de resultaatvariabele. + +Een actie die een lijst ophaalt, geeft altijd drie dingen terug: de **items** zelf, het +**aantal**, en of de lijst **afgekapt** is. + +De antwoorden worden onderweg opgeschoond: alleen de velden waar een proces iets aan heeft, +en steeds onder dezelfde naam. Eén pull request is bij GitHub al gauw vijftien kilobyte aan +gegevens, en een lijst van vijftig daarvan is een procesvariabele waar niemand meer doorheen +komt. Welke velden je precies terugkrijgt, staat per actie in de +[pluginreferentie](plugin.md). + +### Lijsten en onvolledige lijsten + +Een lijstactie stopt na het aantal pagina's dat bij **Maximum aantal pagina's per actie** +staat, en meldt dan dat de lijst is afgekapt. + +**Laat het proces daar altijd op controleren** voordat het concludeert dat er niets meer te +doen is. Een korte lijst en een afgekapte lijst zien er verder precies hetzelfde uit, en een +proces dat vertakt op "geen openstaande pull requests meer" kiest bij een opgeraakt +paginabudget de verkeerde tak. + +## Waar je op moet letten + +Een handvol dingen die bij het inrichten het vaakst misgaan: + +| Onderwerp | Waar je op moet letten | +| --- | --- | +| **Issues opsommen** | Geeft óók pull requests terug. GitHub behandelt die intern als issues. Elk item vertelt zelf of het een pull request is; wil je alleen echte issues, laat het proces daar dan op filteren. | +| **Labels en behandelaars** | Je geeft op wat erbíj moet en wat eraf moet — niet de complete lijst. Dat is met opzet: anders gooi je weg wat een collega er intussen op gezet heeft. Een label weghalen dat er niet op zit, is geen fout. | +| **Doelbranch bij een pull request** | Laat leeg. De plugin gebruikt dan de standaardbranch van de repository zelf, en dat is lang niet overal `main`. | +| **Mergen** | Vul de verwachte commit in die je eerder bij het ophalen van de pull request hebt gekregen. GitHub weigert de merge dan als er intussen nog iets is bijgekomen, in plaats van iets te mergen wat niemand gereviewd heeft. | +| **Zoeken** | Zoeken gaat via de zoekindex van GitHub, niet via de repository zelf. Die index loopt seconden tot minuten achter, dus vlak na een wijziging zoeken kan een verouderd beeld geven. | +| **Checkstatus** | De status is `none` als er nog helemaal niets over die commit gerapporteerd is. Dat is nadrukkelijk niet hetzelfde als geslaagd: laat een proces nooit mergen op `none`. | +| **Label aanmaken** | Een label dat al bestaat levert geen fout op. Je kunt deze stap dus gewoon elke keer uitvoeren, zonder omweg voor het geval hij er al is. | +| **Bestand aanmaken of bijwerken** | Schrijft rechtstreeks naar de branch, zonder pull request ertussen. Hetzelfde pad twee keer schrijven overschrijft de vorige versie. | + +## Wat er mis kan gaan + +Weigert GitHub een aanvraag, dan mislukt de processtap en blijft hij als incident staan. De +melding bevat de reden die GitHub zelf geeft, inclusief het veld waar het om ging. Los de +oorzaak op en voer de taak opnieuw uit. + +De meest voorkomende oorzaken: + +| Wat je ziet | Waarschijnlijke oorzaak | Wat je doet | +| --- | --- | --- | +| Elke actie mislukt meteen | Verkeerd token, verlopen token, of de verkeerde API-URL | Controleer het token en de URL in de configuratie | +| Lezen werkt, schrijven niet | Het token mist het recht om te schrijven | Vraag een token met de juiste rechten, of gebruik een tweede configuratie | +| Een repository "bestaat niet" | Een privérepository waar dit token niet bij mag — GitHub zegt dan niet "geen toegang" maar "niet gevonden" | Controleer of het token toegang tot déze repository heeft | +| Een pull request goedkeuren mislukt | Het token hoort bij het account dat de pull request zelf heeft aangemaakt | Gebruik een tweede account voor de review | +| Een lijst lijkt onvolledig | Het paginabudget is opgeraakt | Verhoog **Maximum aantal pagina's per actie**, of filter de aanvraag scherper | + +## Meer lezen + +- [Pluginreferentie](plugin.md) — alle acties, hun instellingen en wat ze teruggeven +- [Aan de slag](getting-started.md) — de sandbox met voorbeeldprocessen draaien +- [Release-notities](release-notes.md) — wat er per versie is veranderd diff --git a/documentation/release-notes.md b/documentation/release-notes.md index 2d76186..2284e82 100644 --- a/documentation/release-notes.md +++ b/documentation/release-notes.md @@ -2,6 +2,22 @@ Overview of the changes per version of the GitHub plugin. +## 1.0.1 + +Two fixes, both to answers that were wrong rather than missing. + +`get-job-logs` returned no log at all. GitHub serves an Actions log as a redirect to blob +storage, that redirect was not followed, and the empty answer was reported as a log that had +been read — so the one action whose purpose is to say why a job failed said nothing, and said +it had succeeded. The redirect is now followed, deliberately without the configuration's +token: the storage URL carries its own signature, and the host is not GitHub. A log that +GitHub will not hand over — an expired run, a job still starting — now reports `available` +false with the reason. + +A list action that stopped exactly on its configured limit reported `truncated` true even +when the list had ended there. A process branching on that flag took the "there is more to +do" path on every run. Truncation now means something was actually left behind. + ## 1.0.0 First release. Thirty-three actions covering issues, pull requests, reviews, checks, files, diff --git a/frontend/projects/plugin/package.json b/frontend/projects/plugin/package.json index c5762b8..08a03d1 100644 --- a/frontend/projects/plugin/package.json +++ b/frontend/projects/plugin/package.json @@ -1,7 +1,7 @@ { "name": "@valtimo-plugins/github", "license": "EUPL-1.2", - "version": "1.0.0", + "version": "1.0.1", "peerDependencies": { "@angular/common": "19.2.20", "@angular/core": "19.2.20"