Compare commits

...
2 Commits
Author SHA1 Message Date
sneak 041b8feb89 docs: a release procedure from a green main to tagged, packaged artifacts (closes #387)
check / check (push) Failing after 0s
e2e / e2e-firefox (push) Failing after 0s
e2e / e2e-chrome (push) Successful in 2m3s
Add docs/RELEASE.md, linked from README.md's Release Artifacts section, giving
the release procedure as a numbered list: confirm main is green in CI, confirm
the one version in package.json and the two manifests matches the intended tag,
make package from a clean checkout, verify SHA256SUMS, create the annotated tag
vX.Y.Z, then distribute per browser. Each step names who performs it, marks the
owner-only ones, and states the check that it worked. Every repo command cited
(make setup, make check, make package) exists on next; tagging and verification
use standard git and coreutils, and the CRX pack line is README's own.

The per-browser distribution step is written as pending the owner's choice on
issue 386, with the Firefox and Chrome options named but none presented as
settled. Docs only: no code or test changes.

Model: opus-4-8
2026-09-21 12:58:48 +00:00
clawbot 99292b9188 fix: honour a transaction response only for a transaction approval (closes #262)
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
check / check (push) Successful in 1m42s
The liveness fix this issue describes — settle 4001 on release when the window
a retry would use is gone — already landed with
#271. This completes the rest.

AUTISTMASK_TX_RESPONSE now refuses any approval that is not a transaction
approval, so a reject can no longer retire a sign or connection approval, and a
signed artifact never runs the broadcast path against one — which before only
failed closed by throwing deeper in. Tests pin the site-connection port's
approve, reject and disconnect paths against a transaction approval broadcasting
behind them: each is declined and the dApp still receives its broadcast result.

Model: opus-4-8
2026-09-21 09:54:40 +02:00
5 changed files with 266 additions and 1 deletions
+3 -1
View File
@@ -64,7 +64,9 @@ release/SHA256SUMS
```
Nothing is published by this. Tagging, CRX packing and any upload are
outward-facing acts and are the owner's alone.
outward-facing acts and are the owner's alone. The full procedure that turns a
green `main` into a tagged, packaged release — the order of steps, who performs
each, and how to check it worked — is in [docs/RELEASE.md](docs/RELEASE.md).
The archives are deterministic — entries sorted, timestamps fixed, compression
level fixed — so two builds of one commit produce byte-identical files and the
+24
View File
@@ -45,6 +45,30 @@ but the review is broader than any of them.
# Completed Steps
- 2026-09-21: A transaction response is honoured only for a transaction
approval, and the three remaining approval-settlement paths are pinned
([#262](https://git.eeqj.de/sneak/AutistMask/issues/262)). The liveness fix
the issue asks for — settle `4001` on release when the window it would be
retried in is gone — already landed with
[#271](https://git.eeqj.de/sneak/AutistMask/issues/271); this closes the rest.
`AUTISTMASK_TX_RESPONSE` now refuses any approval that is not a transaction
approval, so a reject no longer retires a sign or connection approval and a
signed artifact never runs the broadcast path against one, which before only
failed closed by throwing deeper in. Tests pin the site-connection port's
approve, reject and disconnect paths against a transaction approval
broadcasting behind them: each is declined and the dApp still receives its
broadcast result.
- 2026-09-21: `docs/RELEASE.md`, linked from `README.md`, states the release
procedure as a numbered list a newcomer can follow: confirm `main` is green in
CI, confirm the one version in the three files matches the intended tag,
`make package` from a clean checkout, verify `SHA256SUMS`, tag `vX.Y.Z`, then
distribute per browser. Each step names who performs it (owner-only steps
marked) and the check that it worked. The distribution step is written as
pending the owner's choice on
[#386](https://git.eeqj.de/sneak/AutistMask/issues/386), with the Firefox and
Chrome options named but none settled. Docs only
([#387](https://git.eeqj.de/sneak/AutistMask/issues/387)).
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
wallet list was the reported case: the address shared one row with the
+87
View File
@@ -0,0 +1,87 @@
# Releasing AutistMask
This is the procedure that turns a green `main` into a tagged, packaged release.
It gathers into one place what is otherwise spread across the `Makefile` and
three `README.md` sections, so the person cutting a release does not have to
reconstruct the order from them.
There is one version, declared in three files (`package.json`,
`manifest/chrome.json`, `manifest/firefox.json`), and `make package` builds and
packages but publishes nothing. `make build` and `make package` can be run by
anyone; tagging, signing, packing a CRX and any upload need credentials only the
owner ([@sneak](https://sneak.berlin)) holds and are marked **owner-only**
below. Releases are tagged from `main` (see the Workflow section of `TODO.md`),
so the "release commit" throughout is the `main` commit the milestone PR merged.
## Procedure
1. **Confirm `main` is green in CI.** The `check` workflow
(`.gitea/workflows/check.yml`) runs `script/cibuild`, i.e. `docker build .`,
and the `Dockerfile` runs `make check` as a build step, so a green `check`
run is a green `make check`. Find the run for the exact release commit on the
tracker's Actions view. _Check:_ that commit's `check` run succeeded; running
`make check` on a clean checkout of the commit reproduces it and exits 0.
2. **Confirm the version matches the intended tag.** `package.json`,
`manifest/chrome.json` and `manifest/firefox.json` must all declare the same
`X.Y.Z`. `make build` fails when they disagree, but nothing checks that they
equal the tag you mean to create — that is this manual step. _Check:_ all
three files read the same `X.Y.Z`, and it is the version you intend to tag
`vX.Y.Z`.
3. **Build and package from a clean checkout of that commit.** From a fresh
clone, or a working tree with no local modifications (`git status` clean),
checked out at the release commit: run `make setup`, then `make package`.
`make package` runs `make build` first, so the archives can only be made from
a `dist/` verified against that build's own receipt as a release (not debug)
build. It writes three files into `release/`:
`autistmask-chrome-<version>.zip`, `autistmask-firefox-<version>.xpi`, and
`SHA256SUMS`. _Check:_ those three files exist and `<version>` in the archive
names is the version confirmed in step 2. The Firefox `.xpi` is **unsigned**
(see step 6 and "Installing on Firefox" in `README.md`).
4. **Verify `SHA256SUMS`.** The archives are deterministic — sorted entries,
fixed timestamps, fixed compression — so a second `make package` from another
clean checkout of the same commit produces byte-identical files. Verify the
recorded digests against the files with `sha256sum -c SHA256SUMS`, run from
`release/`. To confirm reproducibility, run `make package` again on a
separate clean checkout and compare the digests. _Check:_ `sha256sum -c`
reports `OK` for every file, and an independent build's digests match.
5. **Tag the release commit.** _(owner-only)_ Create an annotated tag `vX.Y.Z`
on the release commit and push it: `git tag -a vX.Y.Z` (with a message), then
`git push origin vX.Y.Z`. _Check:_ `git tag` lists `vX.Y.Z`, and
`git rev-parse vX.Y.Z^{commit}` resolves to the release commit.
6. **Distribute per browser.** _(owner-only; pending the owner's choice on
https://git.eeqj.de/sneak/AutistMask/issues/386)_ How 1.0.0 is distributed on
each browser is not yet decided; it is the open question on that issue, and
the concrete steps cannot be written until the owner records a choice there.
These steps need credentials only the owner holds. The options under
consideration are:
- **Firefox** — the packaged `.xpi` is unsigned, and release Firefox and ESR
refuse an unsigned add-on:
- (a) AMO self-distribution signing (unlisted): submit the `.xpi` to AMO
with the owner's credentials; AMO returns a signed `.xpi` installable
on every Firefox, with nothing listed publicly.
- (b) AMO listed: as (a), plus a public AMO listing and review.
- (c) Ship the unsigned `.xpi` and state that Firefox support means
Developer Edition, Nightly, or an Unbranded build with
`xpinstall.signatures.required` set to `false`.
- **Chrome** — the repo packs no CRX and publishes nothing; the extension id
is fixed by the `key` in `manifest/chrome.json`:
- (a) Chrome Web Store (unlisted): upload the `.zip` with the owner's
developer account; the store delivers installs and updates.
- (b) Self-hosted CRX signed with the private key the owner holds
(`chrome --pack-extension=dist/chrome --pack-extension-key=<path to the .pem>`),
installable only via enterprise policy on Windows and macOS, so
realistically Linux-only.
- (c) "Load unpacked" from `dist/chrome/` only, as today.
Once the owner decides, the chosen steps — including which credentials they
need and who holds them — are written into this section and `README.md`'s
installation sections are updated to match, which is part of the definition
of done of https://git.eeqj.de/sneak/AutistMask/issues/386. _Check:_ for a
store or AMO route, the artifact installs from the store or AMO on a clean
browser profile; for the CRX or unpacked route, the documented load succeeds
and Chrome reports the extension id `gipbhkogfopeahplcjhipkgpcimdpkip`.
+9
View File
@@ -1344,6 +1344,15 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const approval = pendingApprovals[msg.id];
if (!approval) return false;
// This message signs and broadcasts a transaction, so it is honoured
// only for a transaction approval. A sign or connection approval
// carries no approvedTx, and reaching the broadcast path with one used
// to fail closed by throwing deeper in; refusing here keeps a future
// refactor from turning that incidental throw into a live path, and
// keeps a reject on this message from retiring an approval of another
// kind.
if (approval.type !== "tx") return false;
// A reject arriving while an attempt holds the approval is refused,
// not honoured: the attempt is on its way to broadcasting the
// transaction, and resolving 4001 here would tell the page the request
+143
View File
@@ -1541,6 +1541,149 @@ describe("a claimed approval outlives every other retirement path", () => {
});
});
// The approval popup connects a port named for its approval whatever the
// approval's kind, so a decision or a disconnect on that port can reach a
// transaction approval. Both must be declined: the port decides only
// site-connection approvals, and settling a transaction approval it does not
// own — while an attempt is broadcasting behind it — is the round-3 fund-loss
// bug, where the page is told the request was rejected as the transaction goes
// out. These three paths route through settleApproval() and, before this
// suite, were exercised only against site approvals.
describe("the site-connection port never retires a transaction approval", () => {
async function txMidBroadcast() {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
return { bg, pending, id, inFlight, first };
}
test("an approve on the port does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).decide(true, false);
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
test("a reject on the port does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).decide(false, false);
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
test("a port disconnect does not settle it", async () => {
const { bg, pending, id, inFlight, first } = await txMidBroadcast();
bg.connectApproval(id).disconnect();
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
});
});
// AUTISTMASK_TX_RESPONSE signs and broadcasts a transaction, so it is honoured
// only for a transaction approval. A reject shaped as this message used to
// retire a sign or connection approval outright, and an approve carrying a
// signed artifact used to run the broadcast path against an approval that names
// no transaction, failing closed only by throwing deeper in.
describe("a transaction response is honoured only for a transaction approval", () => {
test("a reject does not retire a sign approval", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
const id = pending.id();
bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
// Still live: its own reject settles it.
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
});
test("a reject does not retire a connection approval", async () => {
const bg = loadBackground({ actionPopup: true });
const pending = bg.requestSite();
await settle();
const id = pending.id();
bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
// Still live: the port that owns it connects the site.
const port = bg.connectApproval(id);
port.decide(true, false);
port.disconnect();
await settle();
expect(pending.result()).toEqual({ result: [signer.address] });
});
test("an approve carrying a signed transaction never broadcasts against a sign approval", async () => {
const bg = loadBackground();
const pending = bg.requestSign();
await settle();
const id = pending.id();
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(pending.result()).toBeNull();
});
});
// A handler that throws must still answer. `sendResponse` is the only thing
// that settles the page's window.ethereum.request() promise, so a throw that
// escapes a handler leaves that promise pending forever — no error, no