Label a pull request into main, inspect the candidate, and merge that pull request normally. A
generated release pull request carrying the version bump then opens against main; merging it starts
publication automatically, pausing once for approval on the production environment. Do not create
or move tags by hand.
Release notes are rendered only from packages/site/src/changelog.json. Add the entry for the new
version to the branch being released before applying the label. Put it at the top and omit date;
Prepare release stamps it:
{ "version": "1.6.0", "changes": [
{ "kind": "new", "text": "Added a feature." }
] }Valid change kinds are new, improved, and fixed. If no entry exists, preparation creates an
empty entry. That intentionally permits build-only releases, but produces an empty release body.
- Open the intended source pull request into
main. For a normal release this is thedeveloptomainpromotion pull request. - Apply exactly one of
release/patch,release/minor, orrelease/major. The label validates the version and builds a candidate. It does not modify the pull request it is applied to. - Candidate automation verifies the labelled head, publishes the signed extension artifacts as the
mutable
vX.Y.ZGitHub prerelease, pushes GHCRcandidate-X.Y.Z, and deploys the site tonext.lurkloot.pages.dev. - Review and merge that pull request normally, with a merge commit.
- Prepare release then cuts
release/X.Y.Zfrom the resulting merge commit onmain, commits the version in all seven workspace manifests, dates the changelog, opens a generated pull request intomain, copies the release label to it, and rebuilds the candidate from the release branch. The generated pull request's diff is only the version bump and changelog date. - Review and merge the generated
release/X.Y.Zpull request with a merge commit. Squash and rebase are blocked onmain; the originaldevelopcommit SHAs remain inmainhistory. - Release runs from the merged
maincommit. Approve its singleproductionjob. It promotes thevX.Y.Zprerelease to the latest release, publishing the extension artifacts it already carries rather than rebuilding them, then publishes GHCR, Chrome Web Store, and the production site, and mergesmaindirectly intodevelopwith the dedicated synchronization App.
workflow_dispatch remains on Release only for idempotent recovery. A successful release does
not require a manual dispatch.
- No recognized label means ordinary pull-request behavior.
- More than one recognized release label blocks preparation and posts an explanatory comment.
- Removing one label while another remains prepares the remaining selection.
- Removing the final release label comments that merging will no longer cut a release. Any candidate already published is left in place, because it may already be under review.
- The release branch is cut only when a labelled pull request merges. Closing one unmerged cuts nothing.
- Generated
release/*pull requests carry their release label for promotion validation but are ignored as new preparation sources. - Fork pull requests cannot prepare releases. In this public repository, native GitHub roles limit
label application to users with triage-or-higher access; currently only
jamezrinhas that access.
Candidate pointers are deliberately mutable:
- GitHub tag/release:
vX.Y.Z, published as a prerelease - GHCR image:
candidate-X.Y.Z - Site:
https://next.lurkloot.pages.dev
Every push to a labelled head rebuilds and refreshes those targets, as does every generated release-branch push, so the candidate always describes the commit that would be released rather than whichever commit was current when the label went on. Ownership metadata binds the candidate release to its pull request. An exact-SHA tag left behind by interrupted initial creation is recovered; a tag at any other SHA is rejected. Automation never modifies a stable release through the candidate path: promotion clears the prerelease flag, and every later candidate build against that tag fails.
The candidate and the stable release are the same object. vX.Y.Z is created during candidacy at the
release branch head and is never moved; merging the release pull request flips it from prerelease to
latest and publishes the artifacts it already carries. Because vX.Y.Z exists while the release is
still under review, it is a moving tag until promotion — it stays out of releases/latest while it
is a prerelease, but anyone pinning the raw tag during that window gets a pre-release commit.
The candidate pipeline writes all five required contexts directly to the generated release PR head:
the four build/verification contexts plus release candidate / ready. This is necessary because
GitHub intentionally suppresses new workflow events caused by the GITHUB_TOKEN that opens the PR.
Only the most recently completed site candidate matters. Concurrent candidates therefore share the
next deployment and the last successful deployment wins.
Different versions may be prepared concurrently. For example, with 1.5.0 stable:
release/1.5.1 <- patch candidate from a hotfix branch based on main
release/1.6.0 <- minor candidate from a develop snapshot
If 1.5.1 publishes first, 1.6.0 remains the correct minor version, but its old candidate does
not contain the hotfix. Merge the updated main into release/1.6.0, resolve manifest or changelog
conflicts by keeping version 1.6.0 and both changelog entries, and push. Do not rebase: merging
preserves the original development commit SHAs. The push rebuilds the candidate before it can merge.
If 1.6.0 publishes first, a pending 1.5.1 is invalid because releases cannot go backwards.
Re-prepare the hotfix as 1.6.1.
Two pull requests may not own the same candidate version. The second preparation fails instead of force-pushing over the first release branch.
Stable publication operates on the exact merged commit and is idempotent:
vX.Y.Zis created once and is never moved.- The signed CRX, Chrome ZIP, Firefox ZIP, Firefox source ZIP, and checksums are uploaded to the stable GitHub release.
- Docker architectures are exported as checksummed OCI archives before approval. GHCR receives
X.Y.Z,X.Y,X, andlatestonly inside the approved production job; an existingX.Y.Zdigest is never replaced. - Chrome Web Store receives the Chrome ZIP with
DEFAULT_PUBLISH; Google publishes it automatically after review approval. - The production site deploys to
https://lurkloot.jamezrin.com. - The owned mutable candidate release and tag are removed after the stable release exists.
mainis merged directly intodevelopafter localpnpm verifysucceeds.
Firefox Add-ons publication remains manual. Upload the Firefox and source ZIPs from the GitHub release to AMO.
- Ordinary feature and fix pull requests into
developuse squash merge. - Release and hotfix pull requests into
mainuse merge commits. - Rebase merging is disabled repository-wide.
- Force pushes and deletions remain blocked on both protected branches.
- The dedicated synchronization App may bypass only the
developpull-request requirement so it can fast-forward or mergemaindirectly after publication. - The general GitHub Actions App is not a bypass actor.
The direct synchronization is a fast-forward when develop has not advanced. When it has advanced,
the sync creates a merge commit, verifies the combined tree, and pushes it. A conflict aborts before
any remote update.
There are exactly two environments:
| Environment | Approval | Credentials and purpose |
|---|---|---|
preview |
none | CRX_PRIVATE_KEY; candidate signing, candidate GHCR, preview site |
production |
jamezrin |
CWS credentials and sync App credentials; all stable publication |
Repository secrets used from both channels remain CLOUDFLARE_API_TOKEN and
CLOUDFLARE_ACCOUNT_ID. Variables are CWS_PUBLISHER_ID and CWS_EXTENSION_ID.
Move CRX_PRIVATE_KEY to preview and CWS_SERVICE_ACCOUNT_JSON to production when their
plaintext values are available. Never delete the repository copy until the environment copy has
been written successfully. Losing the CRX key changes the extension ID.
The production environment additionally requires:
RELEASE_SYNC_APP_IDRELEASE_SYNC_APP_PRIVATE_KEY
The settings template is docs/github-apps/release-sync-manifest.json. GitHub requires the owner to
authorize App registration; a workflow cannot create this identity for itself.
-
In GitHub, open Settings -> Developer settings -> GitHub Apps -> New GitHub App.
-
Use the template name and URL, disable the webhook, keep the App private, grant repository
Contents: Read and write, and subscribe to no events. -
Install the App only on
jamezrin/lurkloot. -
Record the numeric App ID and generate one private key PEM.
-
Store them on the protected environment:
gh secret set RELEASE_SYNC_APP_ID --env production --body "APP_ID" gh secret set RELEASE_SYNC_APP_PRIVATE_KEY --env production < release-sync.private-key.pem
-
Preview the ruleset migration, then apply it using the App ID:
export GITHUB_REPOSITORY=jamezrin/lurkloot export GH_TOKEN="$(gh auth token)" node scripts/release/cli.mjs configure-repository --sync-app-id "APP_ID" node scripts/release/cli.mjs configure-repository --sync-app-id "APP_ID" --apply true
Apply mode enables merge+squash, disables rebase, creates and reads back both active rulesets, and only then removes the conflicting classic protections. It refuses to run without a positive App ID.
main release history targets main and requires:
- pull requests merged with
mergeonly; verify,extension / build, and both Docker architecture checks;release candidate / ready, reported against the PR head by trusted workflow code;- branches up to date with
mainbefore merge; - no deletion or force push;
- no bypass actors.
develop squash history targets develop and requires:
- pull requests merged with
squashonly for ordinary actors; - the four ordinary validation/build checks and strict up-to-date policy;
- no deletion or force push;
- only the Lurkloot Release Sync App as an always-allowed bypass actor.
The repository default workflow token remains read-only.
- Preparation failure: fix the cause and remove/reapply the release label.
- Candidate failure: re-run the failed workflow or push the corrected release branch.
- Stable failure after merge: fix the external/configuration problem and manually dispatch
Release on
main. Matching completed steps are no-ops. - Existing stable tag at another SHA: stop and prepare a new version. Never move it.
- Sync conflict: merge
mainintodeveloplocally, runpnpm verify, and push with the dedicated App credential; alternatively use a one-off reviewed synchronization PR. Do not disable branch protection. - Missing App configuration: publication reports the completed stable steps and fails at sync. Add the two production secrets and rerun Release.
The Chrome Web Store intentionally trails the GitHub release while Google reviews the submission. There is no polling, cancellation, or rollback workflow.