Skip to content

ci(v3): harden the release workflow before its first run - #5815

Merged
leaanthony merged 1 commit into
masterfrom
chore/harden-release-v3
Jul 26, 2026
Merged

leaanthony merged 1 commit into
masterfrom
chore/harden-release-v3

Conversation

@taliesin-ai

@taliesin-ai taliesin-ai commented Jul 26, 2026 •

Copy link
Copy Markdown
Collaborator

Why now

release-v3.yml was added in #5318 and has never executed — 0 runs, ever. Every alpha has shipped through nightly-release-v3.yml. With a beta coming, the tag-triggered release path is about to run for the first time on the release that matters most.

Reviewing it turns up four things that would each produce a failed or misleading release.

The largest: none of the six Apple secrets the workflow reads exist on this repository. Current repo secrets are CHANGELOG_PUSH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, CROWDIN_PERSONAL_TOKEN, NPM_TOKEN, OPENROUTER_API_KEY, SEMGREP_APP_TOKEN, SPONSORS_TOKEN, WAILS_REPO_TOKEN — no APPLE_*, and none inherited from the org. On the first real run the macOS job fails inside security import after three platforms have already built, the release job (which needs it) never runs, and nothing is published.

Changes

  • Preflight job that fails in seconds instead of ten minutes in. It resolves the tag for both trigger paths, checks out that tag, and verifies v3/internal/version/version.txt matches it. That file is go:embed-ed, so it is what wails3 version reports: a mismatch ships a binary that misreports itself with no error at all.
  • Preflight names the missing Apple secrets explicitly, and either stops or — with the new allow_unsigned_macos input — continues with signing and notarization skipped. No silent downgrade in either direction.
  • SHA256SUMS published with the binaries. Today a user has no way to verify a download.
  • Build provenance attestation for every binary. The npm package already ships provenance; the binaries lagging behind it is an inconsistency users can see.
  • New draft input, so the entire path can be rehearsed end to end and the resulting release deleted, without publishing anything.
  • Build jobs take their checkout ref from preflight's resolved tag, so all three platforms provably build the same verified source.

Verification

  • actionlint clean.
  • Version-guard logic exercised locally: rejects v3.0.0-beta.1 against the current version.txt (v3.0.0-alpha2.117), accepts the matching tag.
  • Missing-secret detection exercised under bash -eo pipefail with the secrets both unset and set (the &&-in-loop idiom does not trip set -e in this position — checked, not assumed).
  • Checksum generation exercised, including sha256sum -c round-trip.
  • Not exercised in CI, because that means creating a tag and a release. See below.

The dry run this enables, and one trap in it

With draft: true the release can be rehearsed without publishing. The tag it needs is not free of consequence though: a throwaway tag must sort below the real beta under semver precedence, or it becomes what @latest resolves to for anything that considers prereleases. v3.0.0-test.1 would outrank v3.0.0-beta.1 (test > beta alphabetically) and hijack resolution — the same failure mode as the old alpha.98-tui tag. A tag in the existing alpha2. series (e.g. v3.0.0-alpha2.999) sorts below beta.1 and is safe.

https://claude.ai/code/session_01FigQqUQbNu9ngE4a2CSNm8

Summary by CodeRabbit

  • New Features

    • Added manual release options for draft publishing and unsigned macOS builds when signing is unavailable.
    • Added build provenance attestations and generated SHA256 checksums for release artifacts.
  • Bug Fixes

    • Releases now consistently build from the exact resolved tag across all supported platforms.
    • Added validation to ensure the release tag matches the application version.
    • macOS signing and notarization now run only when valid signing credentials are available.

release-v3.yml was added in #5318 and has never executed: every alpha has
shipped through nightly-release-v3.yml. Reviewing it before the beta turns up
four things that would each surface as a failed or misleading release.

The largest: none of the six Apple secrets it reads (APPLE_SIGNING_CERT,
APPLE_CERT_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_NOTARIZE_USER,
APPLE_NOTARIZE_PASSWORD, APPLE_TEAM_ID) exist on the repository. On the first
real run the macOS job would fail inside `security import` after three
platforms had already built, the release job would never run, and nothing
would be published.

Changes:

- New preflight job that fails in seconds rather than ten minutes in. It
  resolves the tag for both trigger paths, checks out that tag, and verifies
  v3/internal/version/version.txt matches it. version.txt is embedded, so a
  mismatch ships a binary that misreports its own version with no error at all.
- Preflight also reports exactly which Apple secrets are missing, and either
  stops or, with the new allow_unsigned_macos input, continues with the
  signing and notarization steps skipped. No silent downgrade either way.
- Publish SHA256SUMS alongside the binaries. Users currently have no way to
  verify a download.
- Attest build provenance for every binary. The npm package already ships
  provenance; the binaries lagging behind it is a gap users can see.
- New draft input, so the whole path can be rehearsed end to end and the
  resulting release deleted without ever publishing anything.

The build jobs now take their checkout ref from preflight's resolved tag, so
all three platforms provably build the same verified source.

Verified: actionlint clean; the version-guard, missing-secret detection and
checksum generation logic each exercised locally under bash -eo pipefail.
Not exercised in CI, because doing that means creating a tag and a release.

Claude-Session: https://claude.ai/code/session_01FigQqUQbNu9ngE4a2CSNm8
@coderabbitai

coderabbitai Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The release workflow adds preflight tag and version validation, optional unsigned macOS publishing, consistent platform checkouts, conditional signing and notarization, checksum generation, provenance attestations, and draft release support.

Changes

Release workflow

Layer / File(s) Summary
Preflight release validation
.github/workflows/release-v3.yml
Manual inputs control unsigned macOS publishing and draft releases; preflight resolves release metadata, validates the version, and checks Apple signing secrets.
Platform checkout and macOS signing
.github/workflows/release-v3.yml
Linux, Windows, and macOS jobs check out the resolved tag, while macOS signing steps run only when signing is available.
Checksummed artifact publishing
.github/workflows/release-v3.yml
The release job generates SHA256SUMS, emits build provenance attestations, and uses resolved metadata and draft mode when publishing.

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

Sequence Diagram(s)

sequenceDiagram
  participant Workflow
  participant Preflight
  participant PlatformBuilds
  participant ReleaseJob
  participant GitHubRelease
  Workflow->>Preflight: Resolve tag, version, and signing mode
  Preflight->>PlatformBuilds: Provide tag and sign_macos
  PlatformBuilds->>ReleaseJob: Upload platform artifacts
  ReleaseJob->>GitHubRelease: Publish checksums, attestations, and release
Loading

Possibly related PRs

  • wailsapp/wails#5318: Updates the same release workflow with related preflight, macOS signing, checksum, and provenance changes.

Suggested labels: reviewed ✅

Poem

I hopped through preflight, neat and bright,
Checked every tag was tucked in right.
Mac keys guide the signing song,
Checksums guard the builds along.
Draft or publish, the release takes flight!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the workflow hardening change and is concise.
Description check ✅ Passed The description covers motivation, changes, and verification well, though it doesn't fully follow the repository's template.
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 chore/harden-release-v3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@leaanthony leaanthony left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The missing-secrets finding is the important one: with no APPLE_* secrets on the repo, the first tag push would have built three platforms, died in security import, and published nothing. Failing in preflight with the names listed is the right shape.

Version guard is worth having independently of that — an embedded version.txt that disagrees with the tag produces a binary that lies quietly, which is the worst class of release bug.

Checksums and provenance bring the binaries in line with what the npm package already does.

Approving on review; the path itself still needs the draft dry run before we trust it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

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

68-70: 🔒 Security & Privacy | 🔵 Trivial | 🏗️ Heavy lift

Checkouts persist the GitHub token in the workspace for the whole job. None of these actions/checkout@v4 steps set persist-credentials: false, so .git/config retains a usable token for the entire job (readable by go build, module code, or any other step that runs afterward) even though none of these jobs need to push. Same root cause at all four sites; fix them together.

  • .github/workflows/release-v3.yml#L68-L70: add persist-credentials: false to the preflight checkout.
  • .github/workflows/release-v3.yml#L132-L136: add persist-credentials: false to the build-linux checkout.
  • .github/workflows/release-v3.yml#L169-L173: add persist-credentials: false to the build-windows checkout.
  • .github/workflows/release-v3.yml#L201-L205: add persist-credentials: false to the build-sign-macos checkout.

Based on learnings, this hardening should be applied consistently across all workflows in .github/workflows/ in one repo-wide pass rather than piecemeal per file, so treat this as a follow-up rather than blocking this PR.

🤖 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-v3.yml around lines 68 - 70, Add
persist-credentials: false to each actions/checkout@v4 step in
.github/workflows/release-v3.yml at lines 68-70, 132-136, 169-173, and 201-205;
apply the same hardening consistently to all checkout steps across
.github/workflows/ in a repo-wide follow-up pass.

Sources: Learnings, 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-v3.yml:
- Around line 53-84: Replace direct GitHub expression interpolation in the shell
commands of the “Resolve tag and pre-release flag” and “Version file agrees with
the tag” steps with step-level env variables, then reference those variables
inside the scripts. Preserve the existing tag and pre-release resolution
behavior while ensuring inputs.tag, github.ref_name, and steps.meta.outputs.tag
are passed through env rather than embedded in shell code.

---

Nitpick comments:
In @.github/workflows/release-v3.yml:
- Around line 68-70: Add persist-credentials: false to each actions/checkout@v4
step in .github/workflows/release-v3.yml at lines 68-70, 132-136, 169-173, and
201-205; apply the same hardening consistently to all checkout steps across
.github/workflows/ in a repo-wide follow-up pass.
🪄 Autofix (Beta)

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 Plus

Run ID: 371ba289-590f-4e73-873f-7afaa7bff1d3

📥 Commits

Reviewing files that changed from the base of the PR and between 2a4e177 and 2c794b7.

📒 Files selected for processing (1)
  • .github/workflows/release-v3.yml

Comment on lines +53 to +84
- name: Resolve tag and pre-release flag
id: meta
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG="${{ inputs.tag }}"
PRE="${{ inputs.pre_release }}"
else
TAG="${{ github.ref_name }}"
# Tags with a hyphen suffix (v3.0.0-beta.1) are pre-releases.
if [[ "$TAG" == *"-"* ]]; then PRE=true; else PRE=false; fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "pre_release=$PRE" >> "$GITHUB_OUTPUT"
echo "Releasing $TAG (pre-release: $PRE)"

- uses: actions/checkout@v4
with:
ref: ${{ steps.meta.outputs.tag }}

- name: Version file agrees with the tag
run: |
TAG="${{ steps.meta.outputs.tag }}"
FILE_VERSION="$(tr -d '[:space:]' < v3/internal/version/version.txt)"
# version.txt is embedded into the binary, so it is what `wails3
# version` reports. If it disagrees with the tag, the release ships a
# binary that lies about which version it is, silently.
if [[ "$FILE_VERSION" != "$TAG" ]]; then
echo "::error::v3/internal/version/version.txt says '$FILE_VERSION' but the tag is '$TAG'."
echo "::error::The tagged commit must carry the matching version. Re-tag after bumping it."
exit 1
fi
echo "version.txt matches the tag: $FILE_VERSION"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Unquoted expression interpolation into run: is a shell-injection vector.

TAG="${{ inputs.tag }}", TAG="${{ github.ref_name }}", and TAG="${{ steps.meta.outputs.tag }}" (lines 57, 60, 74) expand attacker-influenceable strings directly into the shell script rather than passing them through env:. Git tag names are permitted to contain shell metacharacters (`, $, (, ), ;) — only control chars, space, ~^:?*[\, @{, and slash edge-cases are disallowed — so anyone able to push a tag (or trigger workflow_dispatch with a crafted tag input) can run arbitrary commands in this job, which later checks out and builds the release. The release job further downstream (TAG: ${{ needs.preflight.outputs.tag }} at line 338) already uses the safer env: pattern — the same pattern should be used here.

🔒 Proposed fix using env vars
       - name: Resolve tag and pre-release flag
         id: meta
+        env:
+          DISPATCH_TAG: ${{ inputs.tag }}
+          DISPATCH_PRE: ${{ inputs.pre_release }}
+          PUSH_REF_NAME: ${{ github.ref_name }}
         run: |
           if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
-            TAG="${{ inputs.tag }}"
-            PRE="${{ inputs.pre_release }}"
+            TAG="$DISPATCH_TAG"
+            PRE="$DISPATCH_PRE"
           else
-            TAG="${{ github.ref_name }}"
+            TAG="$PUSH_REF_NAME"
             if [[ "$TAG" == *"-"* ]]; then PRE=true; else PRE=false; fi
           fi
       - name: Version file agrees with the tag
+        env:
+          TAG: ${{ steps.meta.outputs.tag }}
         run: |
-          TAG="${{ steps.meta.outputs.tag }}"
           FILE_VERSION="$(tr -d '[:space:]' < v3/internal/version/version.txt)"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Resolve tag and pre-release flag
id: meta
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG="${{ inputs.tag }}"
PRE="${{ inputs.pre_release }}"
else
TAG="${{ github.ref_name }}"
# Tags with a hyphen suffix (v3.0.0-beta.1) are pre-releases.
if [[ "$TAG" == *"-"* ]]; then PRE=true; else PRE=false; fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "pre_release=$PRE" >> "$GITHUB_OUTPUT"
echo "Releasing $TAG (pre-release: $PRE)"
- uses: actions/checkout@v4
with:
ref: ${{ steps.meta.outputs.tag }}
- name: Version file agrees with the tag
run: |
TAG="${{ steps.meta.outputs.tag }}"
FILE_VERSION="$(tr -d '[:space:]' < v3/internal/version/version.txt)"
# version.txt is embedded into the binary, so it is what `wails3
# version` reports. If it disagrees with the tag, the release ships a
# binary that lies about which version it is, silently.
if [[ "$FILE_VERSION" != "$TAG" ]]; then
echo "::error::v3/internal/version/version.txt says '$FILE_VERSION' but the tag is '$TAG'."
echo "::error::The tagged commit must carry the matching version. Re-tag after bumping it."
exit 1
fi
echo "version.txt matches the tag: $FILE_VERSION"
- name: Resolve tag and pre-release flag
id: meta
env:
DISPATCH_TAG: ${{ inputs.tag }}
DISPATCH_PRE: ${{ inputs.pre_release }}
PUSH_REF_NAME: ${{ github.ref_name }}
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
TAG="$DISPATCH_TAG"
PRE="$DISPATCH_PRE"
else
TAG="$PUSH_REF_NAME"
# Tags with a hyphen suffix (v3.0.0-beta.1) are pre-releases.
if [[ "$TAG" == *"-"* ]]; then PRE=true; else PRE=false; fi
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "pre_release=$PRE" >> "$GITHUB_OUTPUT"
echo "Releasing $TAG (pre-release: $PRE)"
- uses: actions/checkout@v4
with:
ref: ${{ steps.meta.outputs.tag }}
- name: Version file agrees with the tag
env:
TAG: ${{ steps.meta.outputs.tag }}
run: |
FILE_VERSION="$(tr -d '[:space:]' < v3/internal/version/version.txt)"
# version.txt is embedded into the binary, so it is what `wails3
# version` reports. If it disagrees with the tag, the release ships a
# binary that lies about which version it is, silently.
if [[ "$FILE_VERSION" != "$TAG" ]]; then
echo "::error::v3/internal/version/version.txt says '$FILE_VERSION' but the tag is '$TAG'."
echo "::error::The tagged commit must carry the matching version. Re-tag after bumping it."
exit 1
fi
echo "version.txt matches the tag: $FILE_VERSION"
🧰 Tools
🪛 zizmor (1.26.1)

[warning] 68-70: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)


[error] 57-57: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[error] 60-60: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)


[info] 74-74: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 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-v3.yml around lines 53 - 84, Replace direct GitHub
expression interpolation in the shell commands of the “Resolve tag and
pre-release flag” and “Version file agrees with the tag” steps with step-level
env variables, then reference those variables inside the scripts. Preserve the
existing tag and pre-release resolution behavior while ensuring inputs.tag,
github.ref_name, and steps.meta.outputs.tag are passed through env rather than
embedded in shell code.

Source: Linters/SAST tools

@leaanthony
leaanthony merged commit f460aac into master Jul 26, 2026
56 checks passed
@leaanthony
leaanthony deleted the chore/harden-release-v3 branch July 26, 2026 08:41
angaz pushed a commit to beam-transfer/wails that referenced this pull request Aug 2, 2026
Picks up the contribution terms (wailsapp#5816), the release workflow hardening
(wailsapp#5815), the runtime/CLI version lockstep (wailsapp#5820) and the crash fixes merged
since the first sync. Clean merge, no conflicts.

The branch was six commits behind again within hours of the first sync, which
is why this is now an audit blocker rather than something to remember: a
release branch that quietly ages is how a release ships without fixes everyone
believes are in it.

Claude-Session: https://claude.ai/code/session_01FigQqUQbNu9ngE4a2CSNm8
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.

2 participants