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
86 changes: 86 additions & 0 deletions .github/workflows/snap.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Snap

on:
pull_request:
paths:
- ".github/workflows/snap.yml"
- "snap/**"
push:
branches:
- main
paths:
- ".github/workflows/snap.yml"
- "snap/**"
workflow_dispatch:
inputs:
publish_edge:
description: "Publish successful main-branch builds to edge. Stable promotion stays manual."
default: false
required: true
type: boolean

permissions:
contents: read

concurrency:
group: snap-${{ github.ref }}
cancel-in-progress: false

jobs:
build:
name: Build snap (${{ matrix.arch }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 90
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Scope Snap Store credentials only to publishing

Because this secret is defined at job scope, every step receives SNAPCRAFT_STORE_CREDENTIALS, including the Snapcraft build that runs PR-controlled snap/** contents. For same-repository PRs or any merged change to snap/snapcraft.yaml, an override-build/build script can read and exfiltrate the store login before the publish-only if guard is evaluated, so the credential should be passed only to the publish/credential-check path rather than the whole job.

Useful? React with 👍 / 👎.

strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-24.04
- arch: arm64
runner: ubuntu-24.04-arm

steps:
- name: Check out SDK
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Build snap
id: build
uses: snapcore/action-build@3bdaa03e1ba6bf59a65f84a751d943d549a54e79 # v1
with:
path: snap
snapcraft-args: --build-for=${{ matrix.arch }}

- name: Upload snap artefact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: sendmux-snap-${{ matrix.arch }}
path: ${{ steps.build.outputs.snap }}
retention-days: 30

- name: Require Snap Store credentials
if: >-
${{
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || inputs.publish_edge == true) &&
env.SNAPCRAFT_STORE_CREDENTIALS == ''
}}
run: |
echo "::error::SNAPCRAFT_STORE_CREDENTIALS is required to publish Sendmux snap builds to edge."
exit 1

- name: Publish snap to edge
if: >-
${{
github.ref == 'refs/heads/main' &&
(github.event_name == 'push' || inputs.publish_edge == true) &&
env.SNAPCRAFT_STORE_CREDENTIALS != ''
}}
uses: snapcore/action-publish@214b86e5ca036ead1668c79afb81e550e6c54d40 # v1.2.0
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ env.SNAPCRAFT_STORE_CREDENTIALS }}
with:
snap: ${{ steps.build.outputs.snap }}
release: edge
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"packageManager": "pnpm@10.22.0",
"scripts": {
"build": "pnpm drift:check && pnpm -r --if-present build && pnpm verify:layout && pnpm verify:ts-public-api && pnpm verify:tree-shaking && pnpm test:ts-helpers && pnpm test:python-release-guardrails && pnpm test:windows-command-shims && pnpm test:chocolatey-asset-tools && pnpm test:chocolatey-workflow-order && pnpm test:cli-tarball-tools && pnpm check:cli && pnpm check:surface-coverage && pnpm check:live-e2e && pnpm build:go && pnpm build:python && pnpm build:php && pnpm build:ruby && pnpm build:mcp",
"build": "pnpm drift:check && pnpm -r --if-present build && pnpm verify:layout && pnpm verify:ts-public-api && pnpm verify:tree-shaking && pnpm test:ts-helpers && pnpm test:python-release-guardrails && pnpm test:windows-command-shims && pnpm test:chocolatey-asset-tools && pnpm test:chocolatey-workflow-order && pnpm test:snap-workflow && pnpm test:cli-tarball-tools && pnpm check:cli && pnpm check:surface-coverage && pnpm check:live-e2e && pnpm build:go && pnpm build:python && pnpm build:php && pnpm build:ruby && pnpm build:mcp",
"build:go": "pnpm generate:go && node scripts/check-go.mjs && cd go && go test ./... && go vet ./... && go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.3.0 run",
"build:live-e2e-surfaces": "pnpm drift:check && pnpm -r --if-present build && pnpm build:go && pnpm build:python && pnpm build:php && pnpm build:ruby && pnpm build:mcp",
"build:mcp": "pnpm generate:mcp && node scripts/check-mcp.mjs",
Expand Down Expand Up @@ -36,6 +36,7 @@
"live:e2e:plan": "node scripts/run-live-e2e.mjs --plan",
"test:chocolatey-asset-tools": "node scripts/test-chocolatey-asset-tools.mjs",
"test:chocolatey-workflow-order": "node scripts/test-chocolatey-workflow-order.mjs",
"test:snap-workflow": "node scripts/test-snap-workflow.mjs",
"test:cli-tarball-tools": "node scripts/test-cli-tarball-tools.mjs",
"test:ts-helpers": "node scripts/test-ts-helpers.mjs",
"test:python-release-guardrails": "node scripts/test-python-release-guardrails.mjs",
Expand Down
88 changes: 88 additions & 0 deletions scripts/test-snap-workflow.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env node

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

const workflowPath = ".github/workflows/snap.yml";
const workflow = readFileSync(workflowPath, "utf8");
const snapReadme = readFileSync("snap/README.md", "utf8");

assert.match(workflow, /^name: Snap$/m, "Snap workflow must have a stable name.");
assert.match(workflow, /pull_request:[\s\S]*?paths:/, "PR builds must be limited to Snap-related paths.");
assert.match(workflow, /push:[\s\S]*?branches:[\s\S]*?- main/, "Main pushes must run the Snap workflow.");
assert.match(workflow, /workflow_dispatch:/, "Snap workflow must support manual build runs.");
assert.match(workflow, /permissions:[\s\S]*?contents: read/, "Snap workflow must use read-only repository permissions.");
assert.match(
workflow,
/concurrency:[\s\S]*?group: snap-\$\{\{ github\.ref \}\}[\s\S]*?cancel-in-progress: false/,
"Snap workflow must serialise per ref without cancelling in-flight store uploads.",
);
assert.match(workflow, /ubuntu-24\.04-arm/, "Snap workflow must include a native arm64 runner.");
assert.match(workflow, /- arch: amd64/, "Snap workflow must explicitly include amd64 in the build matrix.");
assert.match(workflow, /- arch: arm64/, "Snap workflow must explicitly include arm64 in the build matrix.");
assert.match(
workflow,
/snapcraft-args: --build-for=\$\{\{ matrix\.arch \}\}/,
"Snap workflow must pass the selected matrix architecture to Snapcraft.",
);
assert.match(workflow, /path: snap/, "Snapcraft action must build from the snap project directory.");
assert.match(
workflow,
/SNAPCRAFT_STORE_CREDENTIALS: \$\{\{ secrets\.SNAPCRAFT_STORE_CREDENTIALS \}\}/,
"Snap Store credentials must come from the repository secret expected by snapcore/action-publish.",
);
assert.match(workflow, /release: edge/, "Snap workflow must publish only to edge.");
assert.doesNotMatch(workflow, /release: stable/, "Stable Snap promotion must remain manual.");

const publishStep = stepBlock("Publish snap to edge");
assert.match(
publishStep.text,
/github\.ref == 'refs\/heads\/main'/,
"Snap publishing must be limited to the main branch.",
);
assert.match(
publishStep.text,
/github\.event_name == 'push' \|\| inputs\.publish_edge == true/,
"Snap publishing must happen on main pushes or explicit manual publish runs.",
);
assert.match(
publishStep.text,
/env\.SNAPCRAFT_STORE_CREDENTIALS != ''/,
"Snap publishing must fail closed when store credentials are absent.",
);

assert.match(
snapReadme,
/--acls=package_access,package_push,package_update,package_release/,
"Snap credential docs must keep --acls joined with its value to avoid shell line-break mistakes.",
);
assert.match(
snapReadme,
/test -s "\$secret_file"/,
"Snap credential docs must prove the exported login file is non-empty before setting the GitHub secret.",
);
assert.match(
snapReadme,
/gh secret list --repo Sendmux\/sendmux-sdk/,
"Snap credential docs must require verifying the GitHub secret update timestamp.",
);

console.log("Snap workflow tests passed.");

function stepBlock(name) {
const pattern = new RegExp(`^ - name: ${escapeRegExp(name)}\\n`, "m");
const match = pattern.exec(workflow);
assert(match, `Missing Snap workflow step: ${name}`);

const start = match.index;
const next = workflow.slice(start + match[0].length).search(/^ - name: /m);
const end = next === -1 ? workflow.length : start + match[0].length + next;

return {
text: workflow.slice(start, end),
};
}

function escapeRegExp(value) {
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
63 changes: 55 additions & 8 deletions snap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ public `sendmux` snap.
prospective snaps are publicly uploaded to Launchpad; use a private Launchpad
project if the source should not be public during the build.
- https://documentation.ubuntu.com/snapcraft/stable/explanation/remote-build/
- GitHub Actions can build Snapcraft projects with `snapcore/action-build` and
publish them with `snapcore/action-publish` using exported Snapcraft store
credentials in `SNAPCRAFT_STORE_CREDENTIALS`.
- https://github.com/canonical/action-build
- https://github.com/canonical/action-publish
- GitHub-hosted Linux runners include native `ubuntu-24.04` amd64 and
`ubuntu-24.04-arm` arm64 runners.
- https://docs.github.com/en/actions/reference/runners/github-hosted-runners
- Snap Store listing guidance supports up to 5 screenshots or animated GIFs and
recommends Linux screenshots for cross-platform apps.
- https://forum.snapcraft.io/t/store-listing-and-branding/16397
Expand Down Expand Up @@ -139,15 +147,53 @@ The `home` plug is included for user-selected files passed to `--body-file`,
including binary attachment uploads. It does not grant removable-media access;
add `removable-media` later only if there is a verified user need.

## Publishing Checklist
## GitHub Actions Edge Publishing

The source of truth for Sendmux Snap CI is the repository workflow at
`.github/workflows/snap.yml`. It builds both supported architectures:

- Pull requests touching `.github/workflows/snap.yml` or `snap/**`: build only.
- Pushes to `main` touching `.github/workflows/snap.yml` or `snap/**`: build and
publish successful snaps to `edge`.
- Manual `workflow_dispatch`: build only by default; set `publish_edge=true` on
the `main` branch to publish to `edge`.

Do not also enable Snapcraft dashboard Builds for this same repository and
branch. Snapcraft dashboard Builds are a separate Launchpad-backed integration
that also auto-releases successful builds to `edge`; running both mechanisms for
the same branch can create duplicate edge revisions. If this repository ever
switches to dashboard Builds instead, remove `.github/workflows/snap.yml` in the
same change and document the dashboard configuration.

The workflow expects a repository secret named `SNAPCRAFT_STORE_CREDENTIALS`.
Generate it with a scoped Snapcraft login file, then store the entire file
contents as that GitHub Actions secret:

```sh
secret_file="$(mktemp)"
expires_at="$(date -u -v+1y +"%Y-%m-%dT%H:%M:%SZ")"
snapcraft export-login --snaps=sendmux --channels=edge --acls=package_access,package_push,package_update,package_release --expires="$expires_at" "$secret_file"
test -s "$secret_file"
gh secret set SNAPCRAFT_STORE_CREDENTIALS --repo Sendmux/sendmux-sdk < "$secret_file"
rm -f "$secret_file"
```

The `test -s` line is intentional: GitHub accepts empty secret input, so the
credential file must be proven non-empty before `gh secret set` runs. After
setting the secret, verify `gh secret list --repo Sendmux/sendmux-sdk` shows a
fresh `SNAPCRAFT_STORE_CREDENTIALS` update time. Keep `stable` promotion manual
after the `edge` revision is verified.

## Manual Publishing Checklist

1. Confirm account ownership:
- Create or use the Sendmux Snapcraft publisher account.
- If using a brand account, complete the Snap Store brand setup before release.
2. Register the public snap name:
- `snapcraft login`
- `snapcraft register sendmux`
3. Build the snap from a clean checkout:
3. Build the snap from a clean checkout when the GitHub Actions workflow is not
available or a local/manual fallback is explicitly needed:
- `snapcraft pack`
- Or, after confirming Launchpad public/private upload policy:
`snapcraft remote-build --build-for amd64,arm64`
Expand Down Expand Up @@ -177,8 +223,10 @@ For each future CLI release:
- npm tarball URL in `parts.sendmux.source`
- `parts.sendmux.source-checksum`
- `parts.sendmux.npm-node-version` only when the CLI runtime target changes
3. Rebuild with `snapcraft pack`.
4. Repeat the edge smoke test before promoting to stable.
3. Open and merge a PR with the `snap/snapcraft.yaml` update.
4. Wait for the Snap workflow on `main` to publish both architectures to `edge`.
5. Verify the edge channel map with `snapcraft status sendmux`.
6. Repeat the edge smoke test before promoting to stable.

Checksum helper:

Expand All @@ -189,8 +237,7 @@ shasum -a 512 /tmp/sendmux-cli-<version>.tgz

Then format the value as `sha512/<hex>` for `source-checksum`.

## CI Option
## CI Boundaries

CI can be added later once Snapcraft credentials and a Linux build provider are
explicitly scoped. A safe first CI job would build the snap and upload only to
`edge`; stable promotion should remain manual until edge installs are verified.
The Snap workflow publishes only to `edge`. Do not add automatic `stable`
promotion; promote stable manually after edge readback and smoke testing.
Loading