Skip to content

release: draft until the artifacts exist; derive ci-success from needs (#323) - #329

Merged
charliek merged 7 commits into
mainfrom
feature/plan-024-release-publish
Aug 10, 2026
Merged

release: draft until the artifacts exist; derive ci-success from needs (#323)#329
charliek merged 7 commits into
mainfrom
feature/plan-024-release-publish

Conversation

@charliek

@charliek charliek commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Plan 024, PR 3 of 6 — workstream W-B. Refs #323. Stacked on #326 (merged) and #327.

Two independent problems: the release workflow could publish something broken, and
ci-success — the single required check, which release.yml's ci-gate trusts — was
only partly measuring what it claimed to.


1. The release was published before anything was built

create-release created a public, empty release, then the build jobs filled it in.
Three things went wrong with that, and the first one actually ships a wrong package to
users:

  1. dispatch-apt-charliek fired on needs: linux — before the DMG, and before
    anything guaranteed the release was complete. apt-charliek's collect-debs.sh walks
    releases newest-first and only warns past one carrying no matching .deb, so it
    silently republishes the previous version.

    The precise mechanism matters and the plan had it slightly wrong: apt-charliek
    authenticates with a repo-scoped GITHUB_TOKEN that has no push access to
    charliek/roost, and GitHub lists drafts only to callers with push access — so a
    draft is invisible to it, not merely sorted last.

  2. A failed linux or mac left a public, empty release behind.

  3. create-release blindly reused any existing release, so a re-run after a publish would
    --clobber new assets straight into a live one.

New job graph

create-release (--draft)
  ├─→ linux (amd64 + arm64)
  └─→ mac (DMG + EdDSA sign only)
        └─→ publish-release   ← the only irreversible step
              ├─→ appcast
              └─→ dispatch-apt-charliek

publish-release is where the assertions live, because it is the last moment the fix is
still "delete the draft and re-run": still a draft, and an amd64 .deb, an arm64 .deb
and a .dmg each present exactly once, non-empty, and named for the tag. It
is idempotent — an already-published release is a notice and exit 0. A plain needs:
with no if: always() is what makes a failed build leave the draft unpublished.

create-release now fails closed: reusing an existing draft is the re-run case and
still fine; reusing a published release exits 1.

The appcast had to move, not just be reordered

update-appcast.py builds https://github.com/{repo}/releases/download/{tag}/{dmg} by
string construction and pushes it to Pages. Draft asset URLs are not publicly
resolvable
, so leaving the appcast in mac would have published a Sparkle feed pointing
at a 404 — and Sparkle has no fallback for a dead enclosure.

Signing stays in mac (the only job holding the SwiftPM artifacts that carry
sign_update). The whole sign.txt travels forward as a build artifact, because it
carries both sparkle:edSignature and length and the updater needs both. The new
appcast job range-GETs the enclosure URL and refuses to commit if it does not resolve.

update-appcast.py needed no changes — verified by reading it and by running it in a
scratch directory with no mac/build/ present: it never opens the DMG, taking length
from sign.txt.

Verified empirically before relying on it (plan D2.6)

gh release upload --clobber works against a draft, repeatedly, and creating a draft
does not create or move a git tag. Probed with a throwaway draft on this repo
(uploaded, re-uploaded with a different size to confirm clobber, then deleted);
git ls-remote --tags confirmed clean afterwards. So no REST fallback is needed and the
existing upload steps are unchanged.

I deliberately did not probe gh release edit --draft=false: publishing a draft
materializes the git tag, and a v* tag push would have fired this workflow for real.

Two deliberate deviations from the plan

  • appcast gets permissions: contents: read, not write. Nothing in it writes via
    GITHUB_TOKEN — the commit is pushed with the release-bot App token, which is the only
    identity in main's ruleset bypass list (release.yml appcast bot-push to main rejected by branch protection #136). write would be inert privilege.
  • ROOST_REPO is now passed explicitly to update-appcast.py, so the URL the job
    verifies is byte-identical to the URL the script writes.

RELEASING.md gains a recovery runbook for every new half-finished state, including the
one that happens after the point of no return.


2. ci-success was measuring less than it claimed

Membership is derived now, not restated

It listed its members twice — in needs: and again in a hand-written results string
— and the two drifted twice. Membership now comes from toJSON(needs), which ends the
class rather than adding a third thing to keep in sync. The JSON arrives through env:
and is never interpolated into the shell body (${{ }} inside run: is textual
substitution, i.e. a script-injection surface).

Behavior preserved and slightly hardened: the allowlist stays (a denylist once let
abandoned through and greenwashed #306); added an explicit assertion that changes
itself succeeded — if it fails, every other job reports skipped and the allowlist would
accept the lot — and a non-empty-needs guard. All eight cases exercised against the jq
locally, including the abandoned shape.

⚠️ The reliability evidence for promoting the Wayland lanes was worthless — twice over

The plan already knew continue-on-error makes a job conclusion green by
construction. What it did not know is that the obvious fix is also worthless: the REST
jobs API exposes conclusion and not outcome for steps, so a continue-on-error step
that exited 1 is reported as success there too. My first sweep said "21/21, all steps
ok" and was measuring nothing.

Every number below therefore comes from the step's log body, across 21 main runs
from 2026-07-31 to 2026-08-09:

lane measured action
e2e-gtk-wayland 21/21 clean, runtime stable 19–31s continue-on-error removed, added to ci-success
e2e-gtk's real-input step 21/21 clean continue-on-error removed
e2e-gtk-wayland-drag 3 of 21 FAILED not promoted — see below

The drag lane stays out. 3 runs failed with
FAIL (real-input required): window_metrics returned no window size (runs 31134306062,
31144935154, 31151407004 — all inside one ~5h window on 2026-08-07, so plausibly a
runner-image or seat/uinput blip rather than a steady 14% rate, but a gate cannot be
promoted on a hypothesis). Plan D2.8 says explicitly: if step-level history shows real
failures, promoting is a separate decision — record it and stop. Filed as #328 with
the two candidate causes.

One plan correction: §10 dispositioned e2e-gtk's own continue-on-error as out of
scope because "it is not in ci-success". It is — so a soft step inside it made the
required gate only a partial one. Its history is clean, so it is promoted, which is what
acceptance criterion W-B(6) actually asks for.

That leaves exactly one continue-on-error in ci.yml, on the one lane whose history
does not support promotion — an invariant worth keeping.


Verification and its honest limit

ci-success's jq exercised locally against eight synthetic needs payloads; both
workflows parse; actionlint reports zero new findings (the 3 it does report are
byte-identical to the same 3 on main); update-appcast.py byte-compiles and was
exercised standalone.

The draft flow cannot be fully proven without a real tag. The pieces are tested — the
job graph, draft upload + clobber, the asset-manifest shell block unit-tested standalone
(happy path, empty asset list, zero-byte DMG, duplicate asset, v0.0.18-rc1,
v1.2.3-rc-2, stale-version deb) — but the first real tag remains the first end-to-end
execution
. That is the same limit plan 023 recorded, and it is recorded here rather than
papered over.

Risk if this is wrong

Higher than an ordinary PR, and worth saying out loud: this is the release path. The
failure mode it introduces is a release stuck as a draft — recoverable, invisible to
users, and documented. The failure mode it removes is a release published broken with
apt-charliek republishing the previous version. I would rather debug the first.

No impact on

Dependencies, privacy. No new secrets; the existing release-bot App token moves job but
keeps its scope.

Summary by CodeRabbit

  • Release Improvements

    • CI now requires GTK and headless Wayland end-to-end checks to pass.
    • Releases are created as drafts and undergo stricter artifact validation before publication.
    • macOS signing and update metadata are handled through a reliable post-release process.
    • Safer reruns help prevent accidental publication of incomplete or invalid builds.
    • macOS releases without notarization now include a first-launch notice.
  • Documentation

    • Updated release guidance covers validation, recovery, reruns, signing requirements, and troubleshooting.

charliek and others added 3 commits August 10, 2026 02:11
`ci-success` listed its members twice — in `needs:` and again in a
hand-written `results` string — and the two drifted twice. Membership is
now derived from `toJSON(needs)`, which ends the class rather than adding
a third thing to keep in sync. The JSON arrives through `env:` and is
never interpolated into the shell body: `${{ }}` inside `run:` is textual
substitution, i.e. a script-injection surface.

Behavior is preserved and slightly hardened. The allowlist (success or
skipped; anything else fails) stays — a denylist once let `abandoned`
through and greenwashed #306. Added: an explicit assertion that `changes`
itself succeeded, because if it fails every other job reports `skipped`
and the allowlist would accept the lot; and a guard that the needs list
is non-empty, since `release.yml`'s ci-gate trusts this check. All eight
cases were exercised against the jq locally, including the #306
`abandoned` shape.

## Two lanes promoted, one deliberately not

The plan required re-establishing reliability before promoting anything,
because the old "21 success / 0 failures" evidence was measuring nothing:
`continue-on-error` makes the job conclusion green by construction.

The obvious fix — query step conclusions instead — is also worthless. The
REST jobs API exposes `conclusion` and not `outcome`, so a
`continue-on-error` step that exited 1 is reported as `success` there
too. Every measurement below therefore comes from the step's LOG BODY,
across 21 `main` runs from 2026-07-31 to 2026-08-09.

* `e2e-gtk-wayland` — 21/21 clean. `continue-on-error` removed, added to
  `ci-success`.
* `e2e-gtk`'s real-input step — 21/21 clean. `continue-on-error` removed.
  The plan had dispositioned this one out of scope on the grounds that
  `e2e-gtk` "is not in ci-success"; it is, so a soft step inside it made
  the required gate only a partial one.
* `e2e-gtk-wayland-drag` — **3 of the same 21 runs FAILED**, all with
  `FAIL (real-input required): window_metrics returned no window size`.
  It keeps `continue-on-error` and stays out of `ci-success`. Promoting
  it is now a separate, tracked decision (#328) rather than a pending
  one, and the reason is written where the next person will read it.

That leaves exactly one `continue-on-error` in the file, on the one lane
whose history does not support promotion — an invariant worth keeping.

Refs #323.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
The Release was created public and empty, before a single artifact was
built. Three things went wrong with that, and the first is the one that
actually ships a wrong package:

1. `dispatch-apt-charliek` fired on `needs: linux` — before the DMG, and
   before anything guaranteed the release was complete. apt-charliek's
   `collect-debs.sh` walks releases newest-first and only *warns* past one
   that carries no matching .deb, so it silently republishes the PREVIOUS
   version. (The precise mechanism matters: apt-charliek authenticates
   with a repo-scoped `GITHUB_TOKEN` that has no push access to
   `charliek/roost`, and GitHub lists drafts only to callers with push
   access — so a draft is invisible to it rather than merely sorted last.)
2. A failed `linux` or `mac` left a public, empty release behind.
3. `create-release` blindly reused any existing release, so a re-run after
   a publish would `--clobber` new assets straight into a live one.

The job graph is now:

    create-release (--draft)
      -> linux, mac
      -> publish-release   (asserts the artifact set, then flips)
      -> appcast, dispatch-apt-charliek

`publish-release` is the one irreversible step, so the assertions live
there, where the fix is still "delete the draft and re-run": still a
draft, and an amd64 .deb, an arm64 .deb and a DMG each present exactly
once, non-empty, and named for the tag. It is idempotent — an
already-published release is a notice and exit 0, not a failure. Plain
`needs:` with no `if: always()` is what makes a failed build leave the
draft unpublished.

`create-release` now fails closed: reusing an existing DRAFT is the
re-run case and still fine, but reusing a PUBLISHED release exits 1.

## The appcast had to move, not just be reordered

`update-appcast.py` builds `https://github.com/{repo}/releases/download/
{tag}/{dmg}` by string construction and pushes it to Pages. Draft asset
URLs are not publicly resolvable, so leaving the appcast in `mac` would
have published a feed pointing at a 404 — and Sparkle has no fallback for
a dead enclosure.

Signing stays in `mac`, which is the only job holding the Sparkle SPM
artifacts that carry `sign_update`. The whole `sign.txt` travels forward
as a build artifact, because it carries both `sparkle:edSignature` and
`length` and the script needs both. The new `appcast` job range-GETs the
enclosure URL and refuses to commit if it does not resolve.

`update-appcast.py` needed no changes — verified by reading it and by
running it in a scratch directory with no `mac/build/` present: it never
opens the DMG, taking `length` from `sign.txt`.

Verified empirically before relying on it (plan D2.6): `gh release
upload --clobber` works against a draft, repeatedly, and creating a draft
does not create or move a git tag. Probed with a throwaway draft on this
repo, then deleted; `git ls-remote --tags` confirmed clean.

Two deliberate deviations from the plan:

* `appcast` gets `permissions: contents: read`, not `write`. Nothing in
  it writes via `GITHUB_TOKEN` — the commit is pushed with the
  release-bot App token, which is the only identity in main's ruleset
  bypass list (#136). `write` would be inert privilege.
* `ROOST_REPO` is now passed to `update-appcast.py` explicitly, so the
  URL the job verifies is byte-identical to the URL the script writes.

Preserved unchanged: prerelease detection, CHANGELOG extraction and the
`--generate-notes` fallback, the Gatekeeper first-launch note, the
notarization gating, the apt dispatch's two skip paths, and the
concurrency group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
The pipeline now has an irreversible step in the middle of it, and a
class of half-finished states that did not exist before: a draft holding
some assets, a published release with a stale appcast, a whole-workflow
re-run that is refused on purpose. None of that is discoverable from the
workflow file at 3am.

Adds a "Draft-until-complete, and how to recover" section covering each
state — build job failed, assertions failed, re-running after a
successful release, and the appcast failing *after* the point of no
return — plus four new rows in the symptom table and the corrected job
list.

The appcast failure is stated plainly for what it is: the release is live
and correct, only in-app updates are not offered yet, and re-running one
job fixes it. That is the trade the job split buys, and it is the right
way round — a feed published ahead of the release points every macOS
updater at a 404.

Also refreshed the stale references to the appcast living inside the
`mac` job, and added `ROOST_REPO` to the documented environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The pull request makes GTK and Wayland checks required CI gates. It changes releases to remain drafts until platform artifacts pass validation, then publishes the release before running Sparkle appcast updates and APT dispatch.

Changes

CI gate enforcement

Layer / File(s) Summary
Required GTK and Wayland gates
.github/workflows/ci.yml:1080-1095, .github/workflows/ci.yml:1129-1145, .github/workflows/ci.yml:1201, .github/workflows/ci.yml:1218-1219, .github/workflows/ci.yml:1259-1282
GTK Xvfb and headless Wayland checks now fail on test or timeout errors. Cage/uinput pointer-drag coverage remains non-blocking.
Dynamic CI result aggregation
.github/workflows/ci.yml:1471-1533
ci-success derives job results from needs, requires at least 12 gated jobs, accepts only success or skipped, and requires changes to succeed.

Release publication pipeline

Layer / File(s) Summary
Draft release creation and signing setup
.github/workflows/release.yml:15-32, .github/workflows/release.yml:124, .github/workflows/release.yml:140-159, .github/workflows/release.yml:475-482, .github/workflows/release.yml:494, .github/workflows/release.yml:504-548
The workflow creates or reuses draft releases. The macOS job signs the DMG, transfers sign.txt, and adds first-launch instructions before publication.
Release asset validation and publication
.github/workflows/release.yml:549-682
publish-release waits for both platform builds, validates the draft and expected assets, rejects stale or unexpected assets, and publishes the release idempotently.
Post-publication appcast and APT updates
.github/workflows/release.yml:683-758, .github/workflows/release.yml:827-837
The appcast job verifies the public DMG URL and updates the signed appcast. APT dispatch waits for publish-release.
Release operation guidance
RELEASING.md:28-125, RELEASING.md:154, RELEASING.md:169, RELEASING.md:180-185, RELEASING.md:199-206, RELEASING.md:223-232
The documentation describes draft lifecycle, mandatory signing, separate appcast ownership, recovery procedures, validation failures, and post-publication retries.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LinuxBuild
  participant MacBuild
  participant PublishRelease
  participant GitHubRelease
  participant Appcast
  LinuxBuild->>GitHubRelease: Upload Linux packages
  MacBuild->>GitHubRelease: Upload signed DMG
  PublishRelease->>GitHubRelease: Validate draft and expected assets
  PublishRelease->>GitHubRelease: Publish release
  Appcast->>GitHubRelease: Verify public DMG URL
  Appcast->>GitHubRelease: Update signed appcast
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main release and CI workflow changes in the pull request.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/plan-024-release-publish

Comment @coderabbitai help to get the list of available commands.

@charliek

Copy link
Copy Markdown
Owner Author

Note for the record: CodeRabbit did not actually review this PR — it hit its Fair Usage rate limit and reported pass with a "Review limit reached" notice instead of a review. Since this is the release path, I ran an adversarial correctness review with codex in its place and will re-request CodeRabbit once the cooldown expires. Not merging on the rate-limited green.

charliek and others added 2 commits August 10, 2026 02:54
CodeRabbit was rate-limited on this PR and reported `pass` without
reviewing it, so codex reviewed the release path in its place. It found
four things, and the first is the one that mattered.

**The draft check failed OPEN.** `if [ "$(gh release view … isDraft)" !=
"true" ]` puts the command substitution inside a test, where `set -e`
does not fire. A transient API error yields an empty string, empty is not
"true", and the job then reported "already published", exited 0, and
never published — while `dispatch-apt-charliek` ran against a still-draft
release. That is precisely the bug this job exists to prevent, reachable
by a single flaky API call. The flag is now read into a variable (a
standalone assignment DOES propagate the failure), `false` means
already-published, `true` means publish, and anything else fails closed.

**The asset assertions accepted a truncated or stale set.** They proved
each expected name existed exactly once and was non-zero, which passes
for three 1-byte files, and said nothing about extra assets — so a reused
draft still carrying `roost_<older-version>_amd64.deb` would publish, and
apt-charliek globs `roost_*.deb`. Now: a 1 MiB floor rather than
"non-empty" (every artifact here is multi-megabyte, so `> 0` waves
through exactly the corruption worth catching), and any unexpected asset
is refused.

Exercised standalone against ten inputs, including the exact one codex
constructed to slip through: happy path, all-size-1-plus-stale-DMG,
stale deb from a previous version, truncated DMG, zero-byte deb, missing
arm64, no assets, duplicate name, and both prerelease version shapes
(`v0.0.18-rc1`, `v1.2.3-rc-2` — only the first hyphen becomes a tilde).

**A mac re-run collided with its own artifact.** Artifacts are immutable,
so re-running just the mac job after a later step failed died on the
existing `sparkle-sign` name instead of recovering. `overwrite: true`.

**Four RELEASING.md statements were wrong**, one of them pre-existing and
load-bearing: prereleases do NOT skip Sparkle signing. The signing step
requires the secret unconditionally; only the separate throwaway-*key*
guard is prerelease-exempt, so "cut it as a beta" was advice that would
have failed the same way. Also corrected: apt dispatch and appcast are
parallel siblings so apt may not have fired; a failing DMG-URL check
means the release is NOT fine; and the assertion now does catch
differently-named extras.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
shellcheck SC2086 on the deliberate word-split. Reworked so nothing is
unquoted rather than silencing it: the expected list becomes newline-
separated via `tr`, and the error report pipes through `sed` instead of
splitting on printf's argument list. Same ten-case unit test still
behaves identically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d
@charliek

Copy link
Copy Markdown
Owner Author

Codex reviewed this in CodeRabbit's place (it was rate-limited) and found four issues, all now fixed in 290f5db+073fda3:

  1. The draft check failed open — highest severity. if [ "$(gh release view … isDraft)" != "true" ] puts the substitution inside a test, where set -e doesn't fire. One transient API error → empty string → "already published" → exit 0 without publishing, while dispatch-apt-charliek runs against a still-draft release. That is exactly the bug this job exists to prevent. Now read into a variable (standalone assignment does propagate failure), with true/false handled explicitly and anything else failing closed.
  2. The asset assertions accepted a truncated or stale set — three 1-byte files passed, and extra assets were ignored, so a reused draft carrying roost_<older>_amd64.deb would publish and apt-charliek globs roost_*.deb. Now a 1 MiB floor plus rejection of any unexpected asset. Exercised standalone against ten inputs including codex's exact exploit case; results in the commit message.
  3. A mac re-run collided with its own immutable artifactoverwrite: true on the sparkle-sign upload.
  4. Four RELEASING.md statements were wrong, one pre-existing and load-bearing: prereleases do not skip Sparkle signing, so "cut it as a beta" was advice that would fail identically.

Re-requesting CodeRabbit now that its cooldown has passed.

@charliek

Copy link
Copy Markdown
Owner Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 9 minutes.

@charliek

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@charliek

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

The hosted CodeRabbit was still rate-limited, so this pass came from the
`coderabbit` CLI against the branch diff. Two findings, both real.

* **`ci-success` only rejected fewer than two upstream jobs.** Deriving
  membership from `needs` means a job DELETED from that list silently
  stops being gated — the gate still passes, just over less. The floor is
  the current 12 now. It is one integer rather than a second copy of the
  list, so it cannot drift the way the hand-written `results` string did:
  adding a gate is free, and lowering the number has to be deliberate.
* **The DMG-recovery table row contradicted the prose two sections
  above it.** It said "the release itself is fine" while the narrative
  correctly says macOS users have nothing to download in that state, and
  it implied re-running `appcast` alone would fix a missing asset. It
  cannot; the DMG has to be re-uploaded first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
.github/workflows/release.yml (1)

708-713: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Scope the release-bot token; the actionlint hit here is a false positive.

Two separate points.

  1. zizmor is right. This token inherits every permission the App installation holds, while the sibling mint at .github/workflows/release.yml:848-853 scopes itself with owner + repositories. The token here only needs to push docs/appcast.xml to this repository. Downscope it.

  2. actionlint's client-id / missing app-id errors at lines 710 and 712 are stale action metadata. client-id is a first-class input of actions/create-github-app-token since v3.1.0, and app-id is deprecated in favor of it. No change needed; suppress it if the lint gate is blocking.

🔒 Proposed downscope
       - name: Mint release-bot App token
         id: bot-token
         uses: actions/create-github-app-token@v3
         with:
           client-id:      ${{ secrets.RELEASE_BOT_CLIENT_ID }}
           private-key: ${{ secrets.RELEASE_BOT_APP_KEY }}
+          repositories: ${{ github.event.repository.name }}
+          permission-contents: write
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 708 - 713, Update the “Mint
release-bot App token” step using actions/create-github-app-token so it scopes
the token to this repository with the same owner and repositories inputs used by
the sibling token step, while preserving the existing client-id and private-key
inputs. Do not replace client-id with app-id; suppress actionlint’s stale
metadata warning only if required by the lint gate.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 629-651: Update the release asset validation around expected_names
and the per-name checks to account for GitHub’s prerelease filename
sanitization, matching the stored dot-separated version spelling while
preserving stable-tag behavior. Confirm the spelling expected by collect-debs.sh
and use the same accepted name(s) in the unexpected-asset check so valid
prerelease assets are neither reported missing nor unexpected.

In `@RELEASING.md`:
- Line 111: In RELEASING.md, update the wording around the CDN explanation by
replacing the British spelling “afterwards” with the American spelling
“afterward,” without changing the surrounding text.
- Around line 93-99: Update the post-publication recovery heading in
RELEASING.md to cover both appcast and dispatch-apt-charliek failures, keeping
the existing recovery guidance and parallel-job context unchanged.

---

Nitpick comments:
In @.github/workflows/release.yml:
- Around line 708-713: Update the “Mint release-bot App token” step using
actions/create-github-app-token so it scopes the token to this repository with
the same owner and repositories inputs used by the sibling token step, while
preserving the existing client-id and private-key inputs. Do not replace
client-id with app-id; suppress actionlint’s stale metadata warning only if
required by the lint gate.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 845acef9-269e-41a8-8489-1e0edfb99f6d

📥 Commits

Reviewing files that changed from the base of the PR and between 6b6be00 and bb01ed4.

📒 Files selected for processing (3)
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • RELEASING.md

Comment thread .github/workflows/release.yml Outdated
Comment thread RELEASING.md Outdated
Comment thread RELEASING.md Outdated
@charliek

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Already reviewed.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

CodeRabbit came back from its rate limit and found a real one, and it is
the kind that hides until the worst moment.

GitHub sanitizes release asset filenames on upload, replacing anything
outside `[A-Za-z0-9._-]` with a dot. nfpm puts a `~` in every prerelease
version, so `roost_0.0.18~rc1_arm64.deb` is STORED as
`roost_0.0.18.rc1_arm64.deb`. The manifest check built the expected name
from the on-disk form, so for every prerelease it would have reported
`found 0` and then flagged the very same asset as unexpected —
`publish-release` fails, the draft never flips. Stable tags are
unaffected, which is exactly why it would have hidden until the first
`-rc` tag: the tags used to validate the Sparkle round-trip.

Verified empirically rather than argued, with a throwaway draft on this
repo:

    uploaded: roost_0.0.18~rc1_arm64.deb
    stored:   roost_0.0.18.rc1_arm64.deb

The expected names now go through the same substitution. The unit test
grew the two prerelease shapes as their GitHub-stored names; all nine
cases behave (3 publish, 6 refuse).

Two doc findings also fixed: the post-publication recovery section was
titled as though `appcast` were the only job that can fail there —
`dispatch-apt-charliek` is its parallel sibling and can fail
independently, so it is now named, with the note that a missed apt
dispatch self-heals on the next scheduled re-scan where a missed appcast
does not. And "afterwards" → "afterward".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgxiEBQTqgNjPswKqcs12d

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@RELEASING.md`:
- Around line 103-106: Update the post-publication status summary in
RELEASING.md to distinguish failures of appcast publication,
dispatch-apt-charliek, and DMG availability. Remove the claim that only
docs/appcast.xml can be stale or that new-user downloads are always unaffected,
and align each outcome with the corresponding symptom-table behavior.
- Around line 113-118: Update the DMG URL failure guidance in RELEASING.md to
distinguish CDN propagation delays from a genuinely missing release asset:
instruct operators to retry or wait when the public URL is only lagging, and
reserve gh release upload --clobber for cases where the published release has
been confirmed to lack the DMG.
- Around line 108-110: Update the documented macOS retry guidance around the
appcast rerun instructions to match the workflow’s artifact behavior: either
configure the sparkle-sign upload step to use overwrite: true, or remove the
claim that macOS reruns are safe and idempotent. Anchor the change to the
sparkle-sign artifact upload configuration and the appcast retry documentation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 17ae665e-8193-4ca6-8a92-5aba9f5bb488

📥 Commits

Reviewing files that changed from the base of the PR and between bb01ed4 and 9cee62a.

📒 Files selected for processing (2)
  • .github/workflows/release.yml
  • RELEASING.md

Comment thread RELEASING.md
Comment on lines +103 to +106
`dispatch-apt-charliek` can fail here too, and independently. Its own row in
the symptom table covers it; the short version is that apt-charliek re-scans
on its next scheduled run, so a missed dispatch self-heals where a missed
appcast does not.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Make the post-publication status summary conditional.

RELEASING.md:95-101 says that only docs/appcast.xml can be stale and that nothing is broken for new users. This is false when dispatch-apt-charliek fails independently. The DMG URL failure described at RELEASING.md:113-116 can also prevent downloads. Update the summary to distinguish appcast, dispatch-apt-charliek, and DMG availability.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RELEASING.md` around lines 103 - 106, Update the post-publication status
summary in RELEASING.md to distinguish failures of appcast publication,
dispatch-apt-charliek, and DMG availability. Remove the claim that only
docs/appcast.xml can be stale or that new-user downloads are always unaffected,
and align each outcome with the corresponding symptom-table behavior.

Comment thread RELEASING.md
Comment on lines +108 to +110
For `appcast`: re-run just that job. It re-downloads `sparkle-sign` from the same
workflow run, and `update-appcast.py` dedupes by version and preserves the
prior `pubDate`, so re-runs are safe and idempotent. Two failure modes worth

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- workflow upload step ---'
sed -n '490,525p' .github/workflows/release.yml

printf '%s\n' '--- releasing guidance ---'
sed -n '88,125p' RELEASING.md

printf '%s\n' '--- artifact references ---'
rg -n -C 3 'sparkle-sign|upload-artifact|overwrite' .github/workflows/release.yml RELEASING.md

Repository: charliek/roost

Length of output: 1917


🌐 Web query:

actions/upload-artifact v7 overwrite default false existing artifact rerun behavior

💡 Result:

In the actions/upload-artifact v7 action, the overwrite input parameter defaults to false [1][2]. When overwrite is set to false (the default), the action will fail if an artifact with the specified name already exists [1][3]. If it is set to true, an existing artifact with the same name will be deleted before the new one is uploaded [1][2]. Note that overwriting does not mutate the original artifact; rather, it deletes the existing one and uploads a new one, which results in the new artifact receiving a new ID [1][3]. Regarding rerun behavior, GitHub Actions workflow artifacts are scoped to a specific workflow run [4]. If a workflow is re-run, artifacts from previous attempts may be subject to deletion or replacement depending on how the job is re-run and how the artifacts are named [4]. Users often avoid conflict in rerun scenarios by dynamically naming artifacts (e.g., including ${{ github.run_id }} or ${{ github.run_attempt }}) to ensure uniqueness across attempts [4]. Additionally, users should be aware that when using the newer direct upload feature (by setting archive: false in v7), there have been reported issues where the overwrite: true parameter may not function as expected because the action derives the artifact name from the filename, effectively ignoring the name parameter [5].

Citations:


🌐 Web query:

site:docs.github.com actions rerun failed jobs artifacts previous attempt same workflow run upload artifact

💡 Result:

When you re-run failed jobs in a GitHub Actions workflow, artifacts from the previous, successful attempts within the same workflow run remain available [1]. You can continue to access and download these artifacts using the GitHub Actions UI, the REST API, or the GitHub CLI [1][2][3]. Key considerations include: - Persistence: Artifacts are associated with the workflow run as a whole [3]. Re-running specific jobs does not delete artifacts that were successfully uploaded during earlier attempts of that same run [1]. - Accessing Logs and Data: If you need to review logs or artifacts for a specific job that was re-run, note that log archives for a workflow that was partially re-run will only include the jobs that were re-run in that specific attempt [1]. To obtain a complete set of logs or artifacts across all attempts, you may need to access the data from the individual attempts [1]. - Workflow Integrity: Jobs that depend on artifacts uploaded in previous successful steps will typically be able to access those artifacts, as they remain part of the run's history [3][4]. You can re-run failed jobs via the GitHub UI, by selecting the Re-run jobs dropdown and choosing Re-run failed jobs [5], or by using the GitHub CLI command gh run rerun RUN_ID --failed [5].

Citations:


🌐 Web query:

site:github.com/actions/upload-artifact README overwrite false artifact already exists rerun job

💡 Result:

When using the actions/upload-artifact GitHub Action, the error "artifact already exists" occurs because artifacts are immutable by default [1][2][3]. If your workflow attempts to upload an artifact with a name that has already been used in the same workflow run, the action will fail unless explicitly configured to allow overwriting [3][4]. To resolve this when rerunning a job or running multiple steps that share an artifact name: 1. Enable Overwriting: You can set overwrite: true in your workflow step configuration [2][3]. When enabled, the action will attempt to delete the existing artifact with the same name before uploading the new one [3][5]. Example: - uses: actions/upload-artifact@v4 with: name: my-artifact path: path/to/artifact overwrite: true 2. Important Considerations: - Not Atomic: The overwrite: true operation is not atomic; it performs a delete-then-create sequence [5][6]. - Not for Parallel Jobs: This feature is intended for serial operations (e.g., uploading, modifying, then re-uploading) [5][6]. If you use overwrite: true across parallel jobs (such as a matrix) attempting to write to the same artifact name, you will likely encounter race conditions and intermittent 409 Conflict errors because the jobs will compete to delete and create the same resource [5][6]. - Unique Naming: If your goal is to collect artifacts from parallel jobs, you should instead give each artifact a unique name (e.g., by including matrix variables) and then use a separate step to merge them, rather than attempting to overwrite a single shared name [5][6].

Citations:


Make the documented macOS retry path match the workflow.

actions/upload-artifact@v7 defaults overwrite to false. A rerun retains the existing sparkle-sign artifact and can fail at .github/workflows/release.yml:510-516. Set overwrite: true, or remove the claim that macOS reruns are safe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RELEASING.md` around lines 108 - 110, Update the documented macOS retry
guidance around the appcast rerun instructions to match the workflow’s artifact
behavior: either configure the sparkle-sign upload step to use overwrite: true,
or remove the claim that macOS reruns are safe and idempotent. Anchor the change
to the sparkle-sign artifact upload configuration and the appcast retry
documentation.

Source: MCP tools

Comment thread RELEASING.md
Comment on lines +113 to +118
- **the DMG URL check failed** — the asset is not actually on the published
release, so contrary to the paragraph above **this release is not fine**:
macOS users have nothing to download. `publish-release` asserts the DMG is
present, so reaching this state means it was removed afterward, or the CDN
has not caught up. Re-upload the DMG (`gh release upload <tag>
Roost-X.Y.Z.dmg --clobber`), confirm the public URL resolves, then re-run.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Separate missing-DMG and CDN-lag recovery.

The workflow retries CDN propagation before failing. This paragraph first treats every URL failure as a missing asset, then mentions CDN lag as a second cause, and unconditionally instructs operators to re-upload the DMG. Tell operators to retry or wait when CDN propagation is the cause. Use gh release upload --clobber only after confirming that the published release lacks the DMG.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@RELEASING.md` around lines 113 - 118, Update the DMG URL failure guidance in
RELEASING.md to distinguish CDN propagation delays from a genuinely missing
release asset: instruct operators to retry or wait when the public URL is only
lagging, and reserve gh release upload --clobber for cases where the published
release has been confirmed to lack the DMG.

@charliek
charliek merged commit 55c491c into main Aug 10, 2026
19 checks passed
@charliek
charliek deleted the feature/plan-024-release-publish branch August 10, 2026 08:39
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