You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Discord role sync: with DISCORD_ROLE_SYNC=on, Pixel reads the member and friend roles every minute and updates members.yaml to match. Someone given a role gets that level (new entries get their username as the note), and someone whose role is removed, or who leaves the server, becomes a guest. Admins are never changed. Every change goes through the access store and is audited as the bot. It pauses (reported once) if a configured role is broken or Discord won't list members, and holds back more than 5 removals at once. Turn on the Server Members Intent for the bot in the Developer Portal and run /admin sync before switching it on. See ADR 0020.
Admins can give a capability to everyone at a membership level: /admin capabilities grant-level level:member capability:ha-lights (and revoke-level). A friend grant reaches members too. It's saved in a new optional tiers: section of members.yaml, audited like other access changes, and DMs nobody. A person holds their own grants plus their level's, so promoting or demoting someone changes them at once; being an admin still gives none. /admin capabilities list shows which levels a capability reaches and marks what a person gets from their level. See ADR 0019.
/spacestate is switched off for now because it isn't working. It isn't registered, so run just register dev / just register prod after deploying to remove it from Discord. Its code and config stay in place to turn back on.
Added
A Deploy dev workflow puts a branch or pull request on the Pixel Dev bot without a laptop: GitHub → Actions → Deploy dev → Run workflow with a PR number, branch or SHA, or just deploy-remote dev 123. It runs just check on that commit, publishes :<sha> and :dev to GHCR, rolls pixel-dev, registers dev slash commands and creates a Sentry release in environment dev. It only runs when someone starts it, refuses PRs from forks, and never touches prod. For it to register commands, a maintainer sets github_register_principal_id in infra/envs/dev to pixel-github-dev's principal ID and applies (it can read the dev discord-token and nothing else). See ADR 0018.
Terraform can set the closing-time channel on Azure (discord_closing_time_channel_id in infra/envs/dev and prod). Before, it could only be set by hand on the Container App, and the next apply removed it.
/schedule edit takes duration to change how long a scheduled poll stays open, so it's no longer stuck at what it was created with. Polls posted after the edit use it; one already posted isn't changed. It's refused on a message schedule. Needs just register dev / just register prod.
Fixed
An announcement channel Pixel couldn't use (the bot status and closing-time channels, and channels set with /admin announcements) now starts posting once it's fixed, without a restart or setting it again. Pixel tries it again shortly after Discord says a channel or role changed, and every 5 minutes, and logs discord.announce_enabled / announcements.route_on when it's back. Before, it stayed off until Pixel restarted. A retry that fails the same way isn't reported again.
The reason a channel can't be used is clearer: "there's no channel with that ID" and "the bot can't see the channel" are now told apart, instead of "the channel doesn't exist or the bot can't see it". Pixel also checks its own roles with Discord instead of trusting a cached copy. The runbook has a new troubleshooting entry for this.
The website's landing page opens with the logo's entrance from the launch video: in dark, the original logo's pixels fly in, sharpen into the original and light up with a burst of sparks, then a light runs round its orbits; in light, the printed pixel-art mark flies in the same way. It plays once when it comes into view, and stays still with reduced motion.
/fishbot brings a little QuakeNet nostalgia to Discord: classic call-and-response phrases with autocomplete, feeding and petting the fish, cookie theft, and a harmless trout slap using Discord's user picker. Available to everyone with the usual command rate limits and no pings. It only responds to slash commands; no chat-reading permissions or IRC-specific behaviour.
CHANGELOG conflicts resolve themselves when that is safe. When main's CHANGELOG.md changes (and after each Release), a new workflow merges main into every open PR from this repo that edits it. If CHANGELOG.md is the only conflict, it keeps both sides' [Unreleased] entries, pushes the merge and comments on the PR. It never adds a section twice, puts entries into the new [Unreleased] after a release, and comments with the reason instead when both sides changed the same entry or the file isn't plain Keep a Changelog. Locally, just changelog-merge does the same in a stopped merge or rebase, and just changelog-merge-driver makes git do it by itself. For CI to run on those merges, a maintainer sets up a GitHub App (runbook: "Changelog conflicts").
Changed
CI jobs run only when the files they depend on have not already passed. Each workflow's first job hashes the job's inputs (a list in .github/path-filters.yml, plus the workflow running it) and looks for a marker that an earlier run with exactly those files succeeded. So pushing only a CHANGELOG entry, re-pushing the same code, or opening a docs-only PR no longer runs just check, the Docker build, the docs site build or Terraform validate again. Skipped jobs no longer start a runner. Test-only changes no longer build or publish an image. CD publishes when the image inputs differ from the last publish, so a CD run cancelled by a newer merge no longer leaves code without a GHCR image. Terraform plan jobs that aren't needed never start, so they never wait on an Environment. The changelog gate no longer runs pnpm install. just ci-inputs <list> shows what a list matches; the runbook's "CI and CD" says how to force a rerun.
just secrets-push / just push are now the only writers of the app secrets in Key Vault (Discord token, admins.yaml, Home Assistant, Mastodon, space state, Sentry). Terraform's write_secrets writes only tailscale-auth-key and ghcr-pull-token, and the app-secret Terraform inputs (discord_token, admins_yaml_file, sentry_dsn, spacestate_user and the rest) are gone, so turning on a feature no longer makes an apply demand or collide with secrets the sync tool set. Push an app secret before the apply that turns its feature on. The next just tf-plan dev|prod drops the app secrets Terraform had written from state ("will no longer be managed by Terraform, but will not be destroyed"); nothing leaves Key Vault. Delete the app-secret lines from any secrets.auto.tfvars. See ADR 0017.
Fixed
/spacestate: the Sentry context for a failed call now says why the network failed (errorCause, such as ENOTFOUND, ECONNREFUSED or UNEXPECTED_REDIRECT), and shows the address without its query. Sentry was replacing the old redacted address with [Filtered].
Home Assistant: a call cut off by a dropped connection now says it can't reach Home Assistant, instead of being logged as an unexpected failure and sent to Sentry as "Object captured as exception".
Home Assistant: if Pixel can't reach Home Assistant at startup, it now says so within about 15 seconds (home.connect_failed in the logs, once to Sentry) and keeps trying in the background, waiting longer each time up to a minute. Before, it retried every second forever without a word, and the login and devices checks never ran. When it does connect, it logs home.ready and runs those checks.
/admin level set and capability changes no longer write members.yaml when the file was hand-edited into a state that fails the startup cross-checks (an id in two entries, or an admin with no entry). Before, the file was saved but the in-memory view and the access.changed audit record were not, and the admin got a generic error. Now the change is refused with "The members file is invalid", and nothing is written.
/schedule: a schedule deleted, paused or edited while another one due at the same time was posting no longer goes out as it was. Pixel reads each schedule again right before posting it.
/schedule: a message or poll that {{tokens}} would push past Discord's length limits (2000 characters for a message, 300 for a question, 55 for an answer) is now refused when it's created or edited, instead of failing at every occurrence. One that still comes out too long when it's posted is cut to fit and logged as schedule.clipped.
/schedule edit: changing when, repeat or days now counts from the edit. Before, a weekly post that last went out on Monday and was moved to Thursday at 19:20 could post straight away for that Thursday 19:00, while the reply said next week.
/schedule edit refuses to turn a repeat into a one-off whose start has passed (add a new when), and /schedule resume refuses a one-off whose time passed while it was paused (it says to delete it). Both used to keep a schedule that could never post.
/schedulewhen option: its description names the configured PIXEL_TIMEZONE instead of always saying Europe/Amsterdam. Re-register the commands (just register) for a zone other than Europe/Amsterdam to see it.
Discord: when Discord refused a slow command's acknowledgement (its deferral), the failure is no longer also logged and sent to Sentry as an unhandled rejection with no user or command; it is handled once, by the adapter. A command whose "Checking…" placeholder couldn't be posted (/status, /ha and others) is no longer reported as a failed command: the handler still doesn't run, and the adapter logs it like any other refused acknowledgement.
Discord: only "already acknowledged" (40060) is logged as discord.duplicate_handler (a second Pixel on the token). "Unknown interaction" (10062) means Pixel didn't acknowledge within Discord's 3 seconds, so it is now logged as discord.slow_ack with the command and who ran it, and reported to Sentry as a SlowAcknowledgementError naming the command.
Sentry: traces no longer carry Discord interaction tokens. Every reply to a command goes to a Discord URL with the interaction token in its path, and the request's span (url.full, url.path) and http breadcrumb kept it, so anyone with access to the Sentry project could post as the bot for 15 minutes after each command. Spans now go through the same scrubbing as errors, breadcrumbs and logs, and the token in /interactions/<id>/<token> and /webhooks/<id>/<token> paths is shown as [redacted]. The scrubbing also no longer skips an object that appears twice in what is sent, and keeps an error's message and stack (scrubbed) instead of dropping them.
The Release workflow runs just check on the main commit it is about to release, and only tags, publishes and deploys to prod if it passes. CI on main can be cancelled or red and never runs on the bump commit, so before this nothing tested what reached prod.
Sentry errors from the Docker image now show the TypeScript source. The image ran Node with --enable-source-maps, which rewrote stack frames to src/*.ts before Sentry saw them, so the source maps uploaded for /app/dist never matched and issues had no source context. Node now runs without it and Sentry applies the uploaded maps. Stack traces in the container logs and log files now show the compiled dist/*.js lines.
/admin doors on|off: the home.kind_switched log now names the admin like every other action (Discord ID, name and handle) and carries the command, so moderators can recognise who switched door control. Before, it had only the Discord ID. It is still logged once per switch.
Bot status: a run that had no online post of its own (stopped within the first few seconds, or its online post failed) no longer edits the newest online post to "offline" when it shuts down. During a deploy that post is the new version's, so the channel said Pixel was offline while it was running. It now says offline in a new post and leaves other runs' posts alone, and a run that is shutting down no longer posts "online" afterwards.
Mastodon: a response from the instance is now read only up to the 64 KiB limit. A larger body (declared or streamed without a length) is cancelled instead of buffered in full or left open.
Commands with a form (/schedule, closing time): opening the form now takes a token from the per-person command rate limit (refused and logged as command.rate_limited like any command), and errors reported while the form is being prepared are tied to the person's Discord ID in Sentry, as they are while a command runs. Submitting the form still takes its own token.
/schedule: when no longer guesses. Four digits after a date are read the same with or without a month name: a time (14 nov 1900 is 19:00, 14 nov 2030 is 20:30, as 14-11 2030 already was), and a year only beside another time (14 nov 2030 19:00) or when no clock reads them (14 nov 2099). Months must be English names or their short forms (sep, sept, september), so words like junk, decent or mars are refused instead of read as June, December or March.
CD on main no longer loses an image: runs queue instead of cancelling each other, and each one publishes when the image inputs differ from the last run that published, not just from the previous push. Before, a docs-only merge right after a code merge cancelled that merge's publish and then skipped its own, so the code merge never got a GHCR image or a Sentry release.
The Discord DM sent when a capability is granted or revoked now really has mentions turned off. It was sent with allowedMentions, a field the Discord API ignores, instead of allowed_mentions.
The Sentry heartbeat no longer runs when PIXEL_ENV=local. A laptop running just dev with a DSN used to check in to a shared pixel-local cron monitor, which opened an issue about ten minutes after anyone stopped it. Startup now logs heartbeat.off with the reason. pixel-dev and pixel-prod are unchanged.
/spacestate: Sentry now shows the request and response of each call to the space state API. A failed call carries them as a spacestate_api context on the error, and every call (successful ones too) puts them on the command's trace: the address, the state sent, the HTTP status, the content type and length, a short capped copy of the body, the outcome and how long it took. The user and token are shown as [redacted] and request headers are never sent. Nothing changes without a Sentry DSN.
/spacestate: optional SPACESTATE_ADDITIONAL_HEADERS (a JSON object of header names to values, sent with the state call), for headers the API needs on that call. It is a secret so the values stay out of the public repository: Key Vault spacestate-additional-headers on Azure, switched on by the new spacestate_additional_headers_enabled Terraform variable. Unset, nothing changes.
just pull dev|prod and just push dev|prod: everything needed to work on an environment from any laptop. Each stack's terraform.tfvars is now shared as the Key Vault secret terraform-tfvars (a new tooling group in the secrets manifest that Terraform and the Container App never see). just pull also mirrors the share's members.yaml, devices.yaml and announcement-routes.yaml read-only. just push sends back the tfvars and app secrets, and refuses a tfvars that holds a secret, sets an undeclared variable or is for the other environment: secret Terraform inputs go in gitignored secrets.auto.tfvars. See the runbook's "Pull and push an environment". The first maintainer with a working tfvars runs just push <env> once to share it.
just secrets-pull dev|prod and just secrets-push dev|prod: sync an environment's app secrets (the app group in infra/secrets.manifest.yaml) between its Key Vault and .env.<env> / config/admins.<env>.yaml. A push reads the live vault, shows what would change by name and length (never a value; admins.yaml as a diff, checked against that env's members.yaml), pushes on a yes only if the vault hasn't changed meanwhile, reads it back, and offers to restart. just restart dev|prod restarts the Container App so it reads its secrets again. Needs Key Vault Secrets Officer. See the runbook's "Sync secrets with Key Vault" and ADR 0016.
/spacestate set state:open|close: members with the new spacestate capability can set the space open or closed from Discord. Pixel POSTs to the space's state API (SPACESTATE_API_URL, with the user and token from SPACESTATE_USER and SPACESTATE_TOKEN, which are Key Vault secrets spacestate-user and spacestate-token on Azure) and tells the person nothing changed if the API doesn't answer SUCCESS. The three settings go together or not at all; without them the command says it isn't set up. Grant the capability with /admin capabilities grant, and run just register dev / just register prod after deploying to add the command. Terraform gets a spacestate_api_url variable and the two write-only secrets.
Website: each command on the commands page has a Copy button for its name. Public and private replies, and the capabilities a command needs, now look different from each other, and a tooltip explains each one when you hover over, tap or tab to it. Home Assistant and Scheduled posts sit under Members.
Website: every command, admin ones included, is now a card of its own in the commands page's outline, with a link you can share (such as /commands#ha-set). Links opened straight to a heading no longer land under the top bar.
Privacy notice (docs/privacy.md) linked from /help, with log and Sentry retention and the access/deletion process in the runbook (#7).
Website: the privacy notice is on the docs site, linked from the guide, the sidebar and the maintainers overview.
Changed
Secrets: infra/secrets.manifest.yaml is now the one list of the secrets Pixel keeps in Azure Key Vault (names, environment variables, what turns each on; never values). Terraform derives its Key Vault secrets and the Container App's secret environment variables from it, with the same result as before, and a test fails if config.ts, .env.example, the Terraform variables or the docs disagree with it. just validate-secrets checks the file and lists the secrets. This is groundwork for a Key Vault sync tool (ADR 0015). Nothing to apply or redeploy.
Website: the /feedback card now says that your message and username may be made public, so you shouldn't include anything private.
/feedback now says, in its description and its reply, that your message and username may be made public, and the reply links the privacy notice. /help links the privacy notice on the website (https://pixelbar.nl/pixel/privacy) instead of GitHub. Run just register dev / just register prod after deploying so Discord shows the new /feedback description.
During a deploy, the old version going offline no longer overwrites the new version's "Pixel is online" post. Each run now only updates its own post.
Added
The Release workflow registers prod slash commands after it rolls pixel-prod (a second Environment prod approval), so just register prod is no longer a manual step after a release. It takes the app, guild and token reference from the running pixel-prod. One-time setup: a maintainer sets github_register_principal_id in infra/envs/prod (from az identity show -g pixel-bootstrap -n pixel-github-prod --query principalId -o tsv) and applies prod, which lets pixel-github-prod read the discord-token secret and nothing else (ADR 0014). Until then the job fails with that instruction and just register prod still works.