How we tell Forge app customers what shipped without adding runtime egress
A build-time pattern for surfacing Released updates inside a Forge app without adding runtime egress or another manual release task.
On this page
Shipping a feature does not mean customers know it exists.
We publish BetterBoard release notes roughly once a week. The publishing side was already covered by Released. The missing piece was getting those updates in front of customers while they were using the app.
We wanted the process to require almost no extra work. Manual release tasks have a habit of disappearing when a deployment gets busy. Copying a title and URL into the codebase every week would work for a while, then gradually stop happening.
The outcome we wanted was simple: publish an update in Released, deploy BetterBoard as usual, and let the app surface the new feature automatically.
There was one firm constraint. BetterBoard is eligible for Runs on Atlassian, and we did not want a product notification to introduce runtime egress or change that security boundary.
The approach: treat the announcement as part of the release
The release note already exists by the time we deploy BetterBoard. It changes roughly once per week, usually alongside a new app version. There is little value in asking Released for the same data every time someone opens a board.
Instead, we turn the latest announcement into a build input.
Immediately before the Forge app is built, our deployment runner fetches the latest posts from Released. A small Node script validates the response, selects the newest eligible post, and writes a TypeScript module into the frontend source tree.
Vite then compiles that module into the Custom UI bundle. When BetterBoard runs inside Jira, it reads a normal JavaScript object. The Forge app never contacts Released.
Publishing in Released remains the only editorial step. The next deployment handles everything else.
Why we avoided a runtime integration
Fetching the changelog from Custom UI or a Forge function would require an external domain in the app manifest. Atlassian notes that external permissions can affect Runs on Atlassian eligibility.
That can be the right choice when an app needs live external data. Our announcement data consists of an ID, title, publication date, and link that changes about once a week.
The deployment runner can fetch a public feed without sending Jira data or customer data anywhere. Because the request happens outside the Forge runtime, it does not require a Forge egress permission.
This does not guarantee Runs on Atlassian eligibility by itself. The rest of the app still needs to meet Atlassian’s requirements, which you can check with forge eligibility. It does mean this feature adds no new runtime egress.
Generate a small frontend contract
The generated module contains only the information needed by the notification:
export type BuildTimeAnnouncement = {
id: string;
title: string;
publishedAt: string;
url: string;
};
export const latestAnnouncement: BuildTimeAnnouncement | null = {
id: "post-id",
title: "More Fields and More Control",
publishedAt: "2026-07-29T07:12:07.101Z",
url: "https://hub.released.so/betterboard/changelog/post/more-fields-and-more-control",
};
The checked-in version sets latestAnnouncement to null. Ordinary development, tests, and local builds therefore stay deterministic and work without a network connection.
We used the JSON endpoint that powers our Released embed:
https://api.released.so/embed/<channel-id>/posts
This is not currently documented as a public Released API, so we treat the response as untrusted input. If you implement the same pattern, use a documented feed where one is available and validate everything that crosses the build boundary.
Our script checks:
- the top-level response shape;
- required IDs, titles, slugs, and publication dates;
- maximum field lengths;
- URL-safe slug characters;
- a fixed destination origin.
The public URL is constructed from the validated slug. We do not accept an arbitrary destination URL from the response.
const POST_ORIGIN = "https://hub.released.so";
const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const latest = payload.items
.map(validateItem)
.filter(item => item.publishedAt > LAUNCH_BASELINE)
.sort((a, b) => b.publishedAt - a.publishedAt)[0];
const announcement = latest
? {
id: latest.id,
title: latest.title,
publishedAt: new Date(latest.publishedAt).toISOString(),
url: `${POST_ORIGIN}/betterboard/changelog/post/${latest.slug}`,
}
: null;
The launch baseline prevents the first deployment from announcing an old post as new. We set it to the publication date of the latest BetterBoard post that existed before this feature shipped.
Run the sync before the production build
The order in the deployment workflow is important:
- run: npm ci
- run: npm run sync:whats-new
- run: npm run build
- run: forge deploy --environment production --non-interactive
We use the same sequence for local Forge deployments:
{
"scripts": {
"sync:whats-new": "node scripts/sync-whats-new.mjs",
"forge:deploy": "npm run typecheck:forge && npm run sync:whats-new && npm run build && forge deploy"
}
}
Keeping the live sync out of the regular build command avoids turning every developer build into a request to an external service.
The script writes the generated module to a temporary sibling file and renames it only after validation and serialization succeed. That prevents a failed or interrupted request from leaving half-written TypeScript in the checkout.
Do not block a deployment over a notification
Our sync request has a ten-second timeout.
If Released is unavailable, returns malformed data, or changes its response shape, the script logs a warning and leaves the existing generated module untouched. The Forge deployment continues.
In a clean CI runner, the fallback is normally the checked-in null module. Customers receive the new app version without a notification, and the following deployment can try again.
A product notification should never hold a production fix hostage.
What customers see
When the generated announcement is present, BetterBoard shows a small card beneath the top toolbar. It contains “New in BetterBoard” and the release title.
Selecting the card opens the full Released post through the Forge bridge:
try {
await router.open(announcement.url);
markSeen();
} catch {
// Keep the announcement visible if navigation is cancelled or fails.
}
A small dismiss button records the announcement ID in browser storage. We include the Atlassian account ID in the storage key so different users sharing a browser profile do not inherit each other’s dismissal state.
betterboard:whats-new:v1:<accountId>
Only one post ID needs to be stored. When a later deployment contains a different ID, the new announcement appears automatically.
Browser storage is deliberately best effort. It does not synchronize across devices. Marketplace apps that require cross-device notification state can store the same ID using an Atlassian-hosted persistence option, at the cost of a little more implementation work.
We also added a permanent “What’s new” item to the Help menu. The temporary card draws attention to a new release; the Help menu keeps the full changelog available afterward.
When this pattern works well
Build-time announcements are a good fit when:
- updates follow the same cadence as app deployments;
- the notification only needs a small amount of metadata;
- a delay until the next deployment is acceptable;
- preserving a runtime without external requests matters;
- publishing should remain the only manual content step.
If announcements must appear immediately and independently of deployments, you will need a runtime integration and should assess the resulting manifest permissions and Runs on Atlassian eligibility directly.
For us, the build-time approach closed the gap without creating another weekly chore. We publish the release note once, deploy BetterBoard, and customers can see what changed from inside Jira.