One git tag ships everything, in lockstep: the same vX.Y.Z tag publishes
the Docker image, the npm launcher, and the versioned install.sh — all
carrying the same version number, so npx exepad-app-builder@X.Y.Z up always pulls image
:X.Y.Z. The pipeline is .github/workflows/release.yml
and the tag is the single source of the version — nothing in the repo is bumped
by hand.
| Artifact | Where | Consumed by |
|---|---|---|
ghcr.io/exepad/exepad-app-builder:X.Y.Z + :latest (multi-arch amd64/arm64) |
GHCR | everything below + docker run + deploy templates |
exepad-app-builder@X.Y.Z on npm (dist-tag latest, or next for prereleases) |
npmjs.com | npx exepad-app-builder up |
GitHub Release vX.Y.Z with install.sh/install.ps1 (+ .sha256) pinned to X.Y.Z |
GitHub Releases | curl -fsSL … | bash / irm … | iex |
One-click bundles Exepad-Installer-{Windows,macOS}.zip, -Linux.tar.gz (+ .sha256; assembled by packaging/one-click/build-bundles.sh from the pinned installers) |
GitHub Releases | download → extract → double-click (INSTALL.md) |
Exepad-Installer-Windows.msi (+ .sha256; WiX 5.0.2 from packaging/msi/exepad.wxs, built + msiexec-smoke-tested on windows-latest, uploaded into the release by the msi job) |
GitHub Releases | double-click → setup runs itself |
Why the launcher is not called
exepad. npm refuses that name — its typosquat protection rejects it as "too similar to existing package execa", a hard 403 at publish time that no token, scope or account can get past. The package is named for the repo and image instead. Itsbinis stillexepad, so the installed command is unchanged; only the one-shotnpx exepad-app-builder …form is longer.
Publish order is enforced: image → npm → GitHub Release, so a live launcher can never reference an image tag that doesn't exist yet.
Public releases are cut on the canonical repo, Exepad/exepad-app-builder —
that is the current and only published mode. The workflow decides for itself by
comparing GITHUB_REPOSITORY; there is nothing to configure.
Tagging a fork still runs the pipeline, but deliberately reduced: npm publish is skipped (npm is inherently public — a fork must not take the shared
exepad-app-builder name), cosign signing is skipped (keyless signatures land in the public
Rekor transparency log), and the image builds linux/amd64 only, since the
arm64 leg runs under QEMU for hours (opt back in with the repo variable
EXEPAD_PRIVATE_MULTIARCH=1). Note the image repo is fixed in the workflow's
env.IMAGE_REPO — a fork that wants its own image must change it.
Tag validation, the image build, and the GitHub Release with the pinned
install.sh/install.ps1 plus the one-click bundles run the same either way.
Done once, by a maintainer with repo-admin rights. Items 1–5 are all required, and most fail silently: the release goes green while the install path, report channel, or review request they unlock stays dead.
-
npm trusted publishing (OIDC) — no secret, and nothing that expires. At npmjs.com → the
exepad-app-builderpackage → Settings → Trusted Publisher, add: organization/user Exepad, repository exepad-app-builder, workflow release.yml. Thenpmjob already requestsid-token: writeand passes noNODE_AUTH_TOKEN.Why not a token: npm caps write-capable granular tokens at 90 days, so a token-based pipeline fails on a timer — and it fails after the image has pushed and
:latesthas moved, because npm runs second. This account also uses browser 2FA, which no unattended token flow can satisfy without the bypass-2FA setting. OIDC removes both problems.⚠️ Do not addregistry-url:toactions/setup-nodein that job. It writes an empty//registry.npmjs.org/:_authToken=line into.npmrc, and npm prefers that empty credential over the OIDC exchange — the publish then fails with a 401 that never mentions OIDC (actions/setup-node#1551).Note the chicken-and-egg: npm will not let you configure a trusted publisher for a package that does not exist (npm/cli#8544), so the first ever publish had to be done by hand from a maintainer's machine. That is already done —
exepad-app-builder@1.0.0-rc.3exists.Fallback: if the
NPM_TOKENsecret is set, the job retries with it when the OIDC attempt fails. It must be a granular token with bypass 2FA enabled — without that npm answers403 … Two-factor authentication or granular access token with bypass 2fa enabled is required, which is what thev1.0.0-rc.2run hit. OIDC is always tried first, so configuring a trusted publisher silently retires the token; the token is never written to.npmrcbefore the OIDC attempt, because any_authTokenline at all makes npm skip the exchange (same trap asregistry-url, above).If neither credential works, the release is no longer lost: since 1.0.1 the GitHub Release is gated on the image, not on npm (see item 6 in "Rules baked into the pipeline"). Publish the launcher by hand afterwards and re-run the workflow — the
npmjob detects the version is already on the registry and exits clean. -
GHCR package access + visibility — if the
exepad-app-builderpackage already exists, this repo's Actions cannot push to it by default: open org → Packages →exepad-app-builder→ Manage Actions access and grantExepad/exepad-app-builderWrite before the first release. Then set the package to public in the same settings so anonymousdocker pullworks. (A package created fresh by the first push only needs the visibility flip.) -
get.exepad.com— ✅ done, and it needs no per-release maintenance. A Cloudflare Worker (exepad-get, deployed fromdeploy/get-exepad/) 302s/and/install.shto…/releases/latest/download/install.sh,/install.ps1to its counterpart, and the.sha256paths to the matching checksums. Because everything routes throughreleases/latest, a new release is picked up automatically.Note it is a Worker with a custom domain, not a DNS record plus a Redirect Rule: DNS alone cannot redirect, and Cloudflare Redirect Rules need a
Zone → DNS → Edittoken thatwrangler logindoes not grant. The Worker route creates and owns its own proxied DNS record.Redeploy with
cd deploy/get-exepad && npx wrangler deploy. Expect a minute of HTTP 500 (Cloudflare error 1104) on a freshly created custom domain while it propagates — it clears on its own. -
Private vulnerability reporting — turn on Settings → Code security → Private vulnerability reporting. Until it is enabled the "Report a security vulnerability" advisory link in
.github/ISSUE_TEMPLATE/config.ymland the Security-tab flow SECURITY.md describes both 404 for outside reporters, leaving only the email address as a private channel. -
CODEOWNERS handle is a collaborator —
.github/CODEOWNERSassigns every path to@ucinar. GitHub silently ignores an owner who lacks write access onExepad/exepad-app-builder: no error, no warning, just no review request — so PRs merge without the maintainer review CONTRIBUTING.md promises. Confirm the handle is a collaborator with write access (or switch the file to an org team handle the org owns). -
Add— done, landed after.github/dependabot.ymlv1.0.1. It was held out of the launch snapshot because Dependabot runs the moment the config reaches the default branch: on the first cut it opened 20 PRs within a minute of the repository being created, which would have burned PR numbers 1–20 on bot traffic before a single human contribution. The shipped config is tuned to monthly + grouped with per-ecosystem limits (3/3/2/2), so the first run is roughly 4–10 PRs. Security advisories ignorescheduleand arrive regardless of this file. -
Windows code signing (optional, still pending — it removes the SmartScreen warning on the MSI; the
irm | iexone-liner never triggers SmartScreen either way). Authenticode signing needs a certificate from Azure Trusted Signing (an org-validated Trusted Signing account + certificate profile) or an EV Authenticode CA. The release workflow is already wired for it: set repo secretsAZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET, repo varsEXEPAD_SIGNING_ENDPOINT/EXEPAD_SIGNING_ACCOUNT/EXEPAD_SIGNING_PROFILE, then flip varEXEPAD_CODESIGN=1— the next release's MSI is signed and smoke-tested post-signature.
# 1. Make sure main is green and the CHANGELOG has an entry for this version:
# move the relevant "Unreleased" items under a new "## [X.Y.Z] - YYYY-MM-DD".
# 2. Tag and push (the tag is the single source of the version — package.json
# is stamped in CI; you do NOT bump it in the repo):
git tag vX.Y.Z
git push origin vX.Y.ZThe workflow then: validates the tag → builds/pushes the multi-arch image →
smokes the arm64 image under QEMU → stamps packages/exepad-cli to X.Y.Z,
runs typecheck/tests/build + a smoke test and a <256 KB tarball gate, checks the
image tag it is about to point at actually exists, publishes to npm → creates the
GitHub Release with the pinned install.sh. A failed npm publish no longer takes
the release down with it.
The arm64 smoke gates the GitHub Release. A multi-arch manifest only proves a
build emitted arm64 layers, not that they run — and this project's CI is
otherwise entirely amd64, so before 1.0.1 every Apple Silicon and ARM-server user
was the first person to execute that image. The job asserts it is genuinely on
aarch64 (a silently ignored --platform would otherwise run amd64 and prove
nothing), then boots the studio, serves /auth/status, checks GET /source, and
completes first-run setup — which exercises the auth and SQLite paths rather than
just proving a string was printed. It skips cleanly on an amd64-only private
build. Emulation will not catch timing or arch-specific native-module faults; it
catches an image that does not start, which is the failure that would hit every
ARM user identically.
Deeper per-platform coverage lives in container-smoke, run on demand: a real container on macOS via Colima, and an opt-in Windows/WSL2 job that does not currently pass (see the notes in that file).
Prereleases: tag vX.Y.Z-rc.1-style. They publish under npm dist-tag
next, do not move the image :latest tag, and are marked prerelease on
GitHub — npx exepad-app-builder up for regular users is unaffected.
Warning
A prerelease that is hand-published to npm still moves the latest
dist-tag, because npm applies latest on any publish that does not pass
--tag. 1.0.0-rc.3 was published that way and, never having been git-tagged,
had no image — so plain npx exepad-app-builder up pulled a tag that did not
exist, for everyone, until 1.0.1. If you ever publish by hand, pass
--tag next for a prerelease and check npm view exepad-app-builder dist-tags
afterwards.
The public repo is a filtered snapshot of this private canonical tree,
produced by scripts/publish-oss.sh (an internal tool — it strips itself from
the snapshot along with every other path in .publish-exclude) and force-pushed
as one clean commit. That is why the public history is a single commit per
release rather than the full development log.
That model is now limited to full re-snapshots taken immediately before a
release tag, and the script refuses to run while the public repo has open pull
requests. A force-push rewrites the base every open PR was opened against, and an
outside contributor cannot recover from that from their side. Release tags are
unaffected either way — they are separate refs and keep their commits alive even
when main is replaced.
For anything smaller than a re-snapshot — a doc fix, a one-file change, a contributor's patch — land it on the public repo as an ordinary commit or PR and mirror it back here, or the next snapshot will silently revert it.
npm view exepad-app-builder version # == X.Y.Z
docker manifest inspect ghcr.io/exepad/exepad-app-builder:X.Y.Z | grep -E 'amd64|arm64'
npx exepad-app-builder@X.Y.Z up --dry-run # compose pins :X.Y.Z
curl -fsSLI https://get.exepad.com | head -1 # front door resolves + redirectsRun the docker manifest inspect from a logged-out shell (docker logout ghcr.io)
— it is the only check that catches a package left private.
- Lockstep — launcher version == image tag, published from one tag in one
run. The packaging CI gate (packaging-ci.yml)
asserts every front door uses the single image repo
ghcr.io/exepad/exepad-app-builder. - Pinned, never
:latest— generated compose files record the exact tag. - Forward-only migrations — never re-tag or delete a published version;
users may already have its
/data. A bad release is fixed by shippingX.Y.(Z+1), not by mutatingX.Y.Z. - Thin launcher — the npm tarball must stay KB-sized (CI fails >256 KB); all real weight ships in the image.
- A launcher is never published without its image. The
npmjob runsdocker manifest inspectonghcr.io/exepad/exepad-app-builder:X.Y.Zand refuses to publish if it is missing. Publishing a launcher with no image is the worst state this pipeline can reach, becausenpm publishmoves thelatestdist-tag:npx exepad-app-builder upthen fails for everyone, not just whoever asked for that version. A hand-published1.0.0-rc.3that was never git-tagged did exactly this, and it stayed invisible for a day because--dry-runprints the pull instead of performing it. - The GitHub Release is gated on the image, not on npm.
install.sh,install.ps1, the one-click bundles and the MSI all install from the image and need npm for nothing —install.sh's npx delegation is an optimisation that falls back to its embedded bootstrap. Gating the release on npm meant a credential problem withheld every install path for a version whose image was already published and public, which is what happened tov1.0.0. When npm does not publish, the release notes simply omit the npm section rather than advertising a version the registry does not have.