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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/release-assets-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
name: Release assets gate

# A published release must actually carry the artifacts people came for.
#
# v0.53.0 was published with ONE asset — the compliance report — and a consumer
# fetched it in that state (#395). The cause was ordering, not a build failure:
# the documented release steps had a human run `gh release create` right after
# the tag push, which PUBLISHES immediately. `compliance.yml` triggers on
# `release: published`, so it fired at once and uploaded its report, while
# `release.yml` (triggered by the tag push) was still queued behind a busy
# runner fleet. The binaries arrived minutes later. In between, the release was
# public and unconsumable.
#
# The process fix is to create the release as a DRAFT and publish only once the
# assets are in (see AGENTS.md). This is the check that makes forgetting loud
# rather than silent — a documented step nobody verifies is exactly the class of
# gate this repo keeps finding at the wrong moment.
#
# It runs AFTER publication, so it cannot prevent a bad release; it reports one.
# That is deliberate: GitHub gives no pre-publish hook, and a loud alarm minutes
# after the fact still beats a consumer discovering it.

on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Release tag to audit (e.g. v0.55.0)'
required: true
type: string

permissions:
contents: read

jobs:
assets:
name: Release assets gate
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Assert the release carries its artifacts
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REL_TAG: ${{ github.event.release.tag_name }}
INPUT_TAG: ${{ inputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
TAG="${INPUT_TAG:-$REL_TAG}"
echo "Auditing $TAG"

NAMES="$(gh release view "$TAG" --repo "$REPO" --json assets --jq '.assets[].name')"
echo "Assets present:"
printf '%s\n' "$NAMES" | sed 's/^/ /'

# `compliance.yml` also fires on publish and is additive, so it is not
# required here; these are the artifacts the release exists to deliver.
missing=0
require() {
if ! printf '%s\n' "$NAMES" | grep -qF "$1"; then
echo "::error::missing release asset matching '$1'"
missing=1
fi
}
for target in \
aarch64-apple-darwin aarch64-unknown-linux-gnu \
x86_64-apple-darwin x86_64-unknown-linux-gnu
do
require "$target.tar.gz"
done
require "SHA256SUMS.txt"
require "SHA256SUMS.txt.cosign.bundle"

if [ "$missing" -ne 0 ]; then
echo "::error::$TAG is published but incomplete — consumers can fetch it in this state (#395)."
exit 1
fi
echo "$TAG carries its platform binaries, checksums and signature bundle."
39 changes: 35 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -814,22 +814,53 @@ GitHub REST API).
gh pr merge <PR#> --squash
```

4. **Create release**: Only after merge and main CI passes
4. **Create release**: Only after merge and main CI passes.

**Publish LAST, as a draft first.** `release.yml` creates the release itself
when it does not already exist; a manual `gh release create` right after the
tag push therefore publishes an EMPTY release that the pipeline fills minutes
later. Worse, `compliance.yml` triggers on `release: published`, so it fires
immediately and uploads its report — which is how v0.53.0 came to sit public
carrying one asset while a consumer fetched it (#395). On a busy runner fleet
that window is long.

```bash
# Pull latest main
git checkout main && git pull

# Verify main CI passed
gh run list --branch main --limit 1

# Create and push tag
# Guardrail: the tag must match the committed workspace version
grep -A1 '^\[workspace.package\]' Cargo.toml | grep '^version'

# Create and push tag — this starts the build pipeline
git tag -a vX.Y.Z -m "Release vX.Y.Z"
git push origin vX.Y.Z

# Create GitHub release
gh release create vX.Y.Z --generate-notes
# Create the release as a DRAFT so nothing is public yet
gh release create vX.Y.Z --draft --notes-file notes.md

# Wait for release.yml to finish and upload into the draft
gh run watch <release-run-id>

# Verify the artifacts before anyone can fetch them: checksums AND signature,
# each with a negative control (see release-execution)
gh release download vX.Y.Z -p 'SHA256SUMS.txt*' -p '*.tar.gz'
shasum -a 256 -c SHA256SUMS.txt
cosign verify-blob --bundle SHA256SUMS.txt.cosign.bundle \
--certificate-identity-regexp 'https://github.com/pulseengine/meld/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS.txt

# Only now make it public
gh release edit vX.Y.Z --draft=false
```

`release-assets-gate.yml` runs on publish and fails if the platform binaries,
checksums or signature bundle are absent. It reports rather than prevents —
GitHub has no pre-publish hook — but it makes forgetting this loud.

#### What NOT to do

- **NEVER** release without CI verification
Expand Down
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.55.0] - 2026-09-05

### Changed
- **`--memory shared` now validates its own output by default (#390, #391).**
The single-address-space paths — `--memory shared`, and therefore
`--address-rebase`, `--pack-rebase` and `--share-stack` — run the fused module
through `wasmparser` before writing it. `--no-validate` opts out;
`--validate` still forces it on any path.

This is where meld rewrites most invasively: rebasing absolute addresses and
bridging calling conventions inside one address space. It is also where both
of the last two releases' defects surfaced — #390 emitted invalid wasm at exit
0, and #393 emitted a module that validated but returned the wrong number.
The consumer who reported both noted they would have caught the first months
earlier had this been the default. The cost is one `wasmparser` pass over an
artifact already in memory.

A module that fails validation is **not written**. Previously an invalid
artifact reached disk and only failed later, in whatever consumed it.

### Fixed
- **The shared+rebase warning named "inputs" it could not have meant (#390).**
A consumer whose every input carried relocation metadata was still told an
input lacked it, and went auditing artifacts that were fine. Both that warning
and the `Fusing N components...` line read the *flattened* component list,
which gains an entry per nested sub-component — so two input files printed
"Fusing 4 components". The warning now names the components it means and says
outright that an unrecognised name is not one of your files; `input_count()`
reports what the caller passed.

- **Releases are no longer published before their artifacts exist (#395).**
`release.yml` creates the GitHub release itself, so a manual `gh release
create` after the tag push published an empty release that the pipeline filled
minutes later — and `compliance.yml`, which triggers on publish, uploaded its
report into that window. v0.53.0 was fetched in exactly that state. Releases
are now created as drafts and published only after their assets are uploaded
and verified, and `release-assets-gate.yml` fails loudly if a published
release lacks its platform binaries, checksums or signature bundle.

## [0.54.0] - 2026-09-05

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ exclude = [
]

[workspace.package]
version = "0.54.0"
version = "0.55.0"
authors = ["PulseEngine <https://github.com/pulseengine>"]
edition = "2024"
license = "Apache-2.0"
Expand Down
102 changes: 98 additions & 4 deletions meld-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,24 @@ enum Commands {
#[arg(long)]
preserve_names: bool,

/// Validate output with wasmparser
/// Validate output with wasmparser. Implied on the single-address-space
/// paths (`--memory shared`, and therefore `--address-rebase`,
/// `--pack-rebase`, `--share-stack`); pass this to force it elsewhere.
#[arg(long)]
validate: bool,

/// Skip the validation that `--memory shared` implies.
///
/// Validation is on by default there because that is where meld does its
/// most invasive rewriting — rebasing addresses and bridging calling
/// conventions inside one address space — and a defect shows up as a
/// module that no runtime accepts. #390 and #393 both shipped as invalid
/// or wrong output at exit 0; the reporter noted they would have caught
/// the first months earlier had this been the default. The cost is one
/// wasmparser pass over an artifact just built.
#[arg(long, conflicts_with = "validate")]
no_validate: bool,

/// Output as P2 component instead of core module
#[arg(long)]
component: bool,
Expand Down Expand Up @@ -257,6 +271,7 @@ fn main() -> Result<()> {
dwarf,
preserve_names,
validate,
no_validate,
component,
emit_import_map,
opaque_rep,
Expand All @@ -281,6 +296,7 @@ fn main() -> Result<()> {
dwarf,
preserve_names,
validate,
no_validate,
component,
emit_import_map,
opaque_rep,
Expand Down Expand Up @@ -362,6 +378,7 @@ fn fuse_command(
dwarf: String,
preserve_names: bool,
validate: bool,
no_validate: bool,
component: bool,
emit_import_map: Option<String>,
opaque_rep: Vec<String>,
Expand Down Expand Up @@ -567,10 +584,27 @@ fn fuse_command(

let elapsed = start.elapsed();

// Validate if requested
if validate {
// Validate the output.
//
// On by default for the single-address-space paths, because that is where
// meld rewrites most invasively — rebasing absolute addresses and bridging
// calling conventions within one memory — and where a defect surfaces as a
// module no runtime will accept. #390 emitted invalid wasm at exit 0 and
// #393 emitted a module that validated and returned the wrong number; both
// reached a consumer. The cost of catching the first class is one
// wasmparser pass over an artifact already in memory.
//
// `--validate` forces it on any path; `--no-validate` opts out.
let implied = validation_is_implied(&memory_strategy, no_validate);
if validate || implied {
println!();
println!("Validating output...");
if implied && !validate {
println!("Validating output (implied by --memory shared; --no-validate to skip)...");
} else {
println!("Validating output...");
}
// Before the write: a module that fails here must not become a file
// somebody can pick up.
validate_wasm(&fused_bytes)?;
println!(" Validation passed");
}
Expand Down Expand Up @@ -1131,3 +1165,63 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
}

/// Does the memory strategy imply validating the output? (#390 / #391)
///
/// Pulled out as a total function over its two inputs so the decision can be
/// enumerated rather than reasoned about: three strategies x two opt-out states
/// is six cases, all covered below. meld#397 makes the case that meld's own
/// decision seams deserve this treatment; this is a small one, so it gets it.
fn validation_is_implied(strategy: &MemoryStrategy, no_validate: bool) -> bool {
if no_validate {
return false;
}
// Shared memory is where meld rebases absolute addresses and bridges
// calling conventions inside one address space — the rewriting whose
// failures (#390, #393) reached consumers as invalid or wrong output.
// `--memory multi` leaves each module's memory alone, so its output is not
// exposed to that class and validation stays opt-in there.
matches!(strategy, MemoryStrategy::SharedMemory)
}

#[cfg(test)]
mod validate_default_tests {
use super::*;

/// Every combination, not a sample: the point of a total function is that
/// its table can be written down.
// rivet: verifies SR-73
#[test]
fn validation_implication_table() {
use MemoryStrategy::*;
for (strategy, no_validate, expected) in [
(SharedMemory, false, true),
(SharedMemory, true, false),
(MultiMemory, false, false),
(MultiMemory, true, false),
(Auto, false, false),
(Auto, true, false),
] {
assert_eq!(
validation_is_implied(&strategy, no_validate),
expected,
"strategy={strategy:?} no_validate={no_validate}"
);
}
}

/// The opt-out must win regardless of strategy — otherwise `--no-validate`
/// silently does nothing on exactly the path where someone would reach for
/// it.
// rivet: verifies SR-73
#[test]
fn no_validate_always_wins() {
for strategy in [
MemoryStrategy::SharedMemory,
MemoryStrategy::MultiMemory,
MemoryStrategy::Auto,
] {
assert!(!validation_is_implied(&strategy, true));
}
}
}
Loading
Loading