Skip to content

desktop-release

desktop-release #725

name: Linear Release
# Stamp Linear issues with the Open Knowledge release they ship in.
#
# WHAT THIS DOES
# On every OK release cut (beta cadence + stable promotion) this attaches the
# Linear issues referenced in the release's commits to a release in the Linear
# "OpenKnowledge" scheduled pipeline. A beta cut advances ITS OWN release to
# the `Beta` stage; a stable promotion completes ITS OWN release into
# `Released`. They are two different release objects, not one advanced then
# completed — see RELEASE IDENTITY below. Each attached issue then shows both
# the beta and the stable it shipped in, in its Linear sidebar. Uses Linear's
# first-party CLI via linear/linear-release-action.
#
# HOW IT'S TRIGGERED (no edits to the delicate publish path)
# release.yml (beta) and promote-stable.yml (stable) BOTH already fire a
# repository_dispatch event_type=desktop-release with
# client_payload.{release_tag, ref} right after cutting the tag + draft
# Release. We piggyback on that same event rather than adding a new dispatch,
# so release.yml / promote-stable.yml stay byte-untouched. We cannot use
# `release: published`: GitHub suppresses cascades from GITHUB_TOKEN-authored
# release events, and repository_dispatch is the documented exemption (which
# is exactly why the desktop-release cascade uses it).
#
# Channel is derived from the tag shape: a `-beta.N` suffix => beta (started
# stage `Beta`); a bare vX.Y.Z => stable, which COMPLETES the release so it
# lands in the pipeline's built-in completed stage `Released`. Two channels,
# two resting states: `Beta` (in flight) and `Released` (shipped to prod).
# There is no started `Stable` stage — Linear's built-in Planned / Released /
# Canceled stages cannot be renamed (only `started` stages are user-editable),
# so `Released` IS the stable/production terminal. Don't re-add a `Stable`
# started stage: `complete` moves the release straight past it, so nothing
# would ever rest there.
#
# RELEASE IDENTITY (the tag is used verbatim)
# A cut stamps the release named by its OWN tag: `v0.46.0-beta.3` keys the
# release `0.46.0-beta.3`, and the stable promotion keys `0.46.1`. A ticket
# therefore ends up on two releases — the beta it first shipped in, and the
# stable that shipped it.
#
# This used to strip `-beta.N` and key every beta of a cycle onto the
# remaining X.Y.Z. That number is a PREDICTION made at cut time; the real
# stable version is computed later at promote time against a delta that has
# moved. Keying on it attributed tickets to versions that never carried them,
# always in the same direction (too early). A tag's own name cannot be wrong.
#
# STABLE ATTRIBUTION IS CONTAINMENT, NOT SCAN RANGE
# The CLI's sync answers "which pull requests fall in this cut's scan range".
# That is right for a beta, which genuinely reports what is new in it, and
# wrong for a stable, where the useful fact is the earliest build a user can
# install to get the fix. So on a stable cut a second pass recomputes the
# STABLE attachment by tag containment and corrects it; betas are never
# touched. It refuses rather than guesses: a ticket whose fix commit cannot be
# resolved keeps whatever it had. Ships in dry-run until the STAMP_MODE repo
# variable is set to `live`.
#
# ONE-TIME SETUP
# 1. A Linear SCHEDULED pipeline "OpenKnowledge" (team Product) with exactly
# one custom started stage named `Beta`. Shipped-to-production maps to the
# built-in completed stage `Released`. [done]
# 2. The pipeline access key (Linear -> Settings -> Releases -> pipeline)
# added as the repo secret LINEAR_ACCESS_KEY on inkeep/open-knowledge.
# A pipeline access key is REQUIRED — a personal Linear API key will not
# work. Until the secret exists this workflow no-ops (see the guard).
# 3. Optional: a Linear status automation — on release completion (stage
# `Released`), move its issues to a Done status.
# 4. Coverage: issues are attached by scanning commit text for Linear
# identifiers (e.g. PRD-1234). A ticketed fix whose PR title lacks the
# identifier will not be attached — put PRD-#### in the PR title.
#
# VALIDATION
# After adding the secret, dry-run against a recent tag before trusting it:
# run this workflow via workflow_dispatch with a real release_tag and read
# the CLI output. (linear-release-action also supports dry_run for the CLI.)
on:
repository_dispatch:
# Fired by release.yml (beta) and promote-stable.yml (stable) after each
# tag + draft Release. Carries client_payload.{release_tag, ref}.
types: [desktop-release]
# Manual re-stamp / backfill for a specific tag.
workflow_dispatch:
inputs:
release_tag:
description: "Release tag to stamp in Linear (e.g. v0.35.0 or v0.35.0-beta.3)"
required: true
type: string
permissions:
contents: read
concurrency:
# Serialize re-runs of the SAME tag, so a manual workflow_dispatch cannot
# overlap a still-running dispatch-triggered stamp of that tag. A beta and its
# stable no longer need serializing against each other: they key different
# tags, and therefore different Linear release objects.
group: linear-release-${{ github.event.client_payload.release_tag || inputs.release_tag }}
cancel-in-progress: false
jobs:
stamp:
runs-on: ubuntu-latest
timeout-minutes: 10
env:
# Secrets are unavailable in step/job `if` on some event types, so map
# presence to a plain string once and gate steps on that.
HAS_KEY: ${{ secrets.LINEAR_ACCESS_KEY != '' }}
RELEASE_TAG: ${{ github.event.client_payload.release_tag || inputs.release_tag }}
steps:
- name: Skip when access key is not configured
if: env.HAS_KEY != 'true'
run: |
echo "::notice::LINEAR_ACCESS_KEY is not set on this repo — skipping Linear release stamping. Add the OpenKnowledge pipeline access key as a repo secret to enable."
- name: Checkout at the release tag
if: env.HAS_KEY == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.client_payload.ref || inputs.release_tag }}
# Full history + tags so the commit scan and previous-tag lookup work.
fetch-depth: 0
fetch-tags: true
- name: Restore stamping scripts from workflow revision
if: env.HAS_KEY == 'true'
env:
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: git restore --source "$WORKFLOW_SHA" --worktree .github/scripts/
- name: Set up Node
if: env.HAS_KEY == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
# The script has no dependencies beyond node: builtins, so there is
# nothing to install between setup and this call.
- name: Derive channel, version, and scan range
if: env.HAS_KEY == 'true'
id: derive
run: node .github/scripts/derive-release-stamp.mjs "${RELEASE_TAG}"
# Create/find the release keyed by this cut's own tag and attach the
# Linear issues referenced in <base_ref>..HEAD. Runs on both channels;
# idempotent.
- name: Sync issues into the Linear release
if: env.HAS_KEY == 'true'
uses: linear/linear-release-action@c0cb8354a362c24c6d3e0948f37fd66d07588e3f # v0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: sync
version: ${{ steps.derive.outputs.version }}
name: ${{ steps.derive.outputs.name }}
base_ref: ${{ steps.derive.outputs.base_ref }}
# Beta cut only: advance the release to the started `Beta` stage. A stable
# promotion has no started-stage step — reaching stable IS completion, so
# the `complete` step below moves the release straight to the completed
# `Released` stage (two-channel model: stable = production = done).
#
# A beta release RESTS in `Beta` and is never completed: it is a historical
# record of a beta cut, and the stable that supersedes it is a separate
# release object with its own identity. So the two stages partition the
# pipeline cleanly — `Beta` holds every beta ever cut, `Released` holds
# every stable. Nothing is left half-finished.
- name: Advance to Beta stage (beta cut)
if: env.HAS_KEY == 'true' && steps.derive.outputs.channel == 'beta'
uses: linear/linear-release-action@c0cb8354a362c24c6d3e0948f37fd66d07588e3f # v0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: update
version: ${{ steps.derive.outputs.version }}
stage: Beta
# Stable promotion only: mark shipped-to-production, moving the release to
# the pipeline's completed stage (`Released`) and firing any completion-based
# Linear status automations.
- name: Complete the release (stable only)
if: env.HAS_KEY == 'true' && steps.derive.outputs.channel == 'stable'
uses: linear/linear-release-action@c0cb8354a362c24c6d3e0948f37fd66d07588e3f # v0
with:
access_key: ${{ secrets.LINEAR_ACCESS_KEY }}
command: complete
version: ${{ steps.derive.outputs.version }}
# The sync above attributes by scan range, which is the right answer for a
# beta ("what is new here") and the wrong one for a stable, where the
# useful fact is the earliest build carrying the fix. This pass recomputes
# the STABLE attachment by tag containment and corrects it. Betas are left
# alone. Reads the fix pull requests out of the private monorepo, which
# this repo's own token cannot see, hence the bridge App.
- name: Check whether the bridge App is configured
if: steps.derive.outputs.channel == 'stable'
id: bridge-check
env:
OK_RELEASE_BRIDGE_APP_ID: ${{ secrets.OK_RELEASE_BRIDGE_APP_ID }}
run: |
set -euo pipefail
if [[ -n "${OK_RELEASE_BRIDGE_APP_ID:-}" ]]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
else
echo "configured=false" >> "$GITHUB_OUTPUT"
echo "::warning::OK_RELEASE_BRIDGE_APP_ID not set — private-monorepo fix commits cannot be resolved, so the containment pass fails rather than mis-stamping."
fi
- name: Mint cross-repo read token (bridge App)
if: steps.derive.outputs.channel == 'stable' && steps.bridge-check.outputs.configured == 'true'
id: bridge-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
# Public client ID for inkeep-ok-release-bridge (non-secret).
client-id: Iv23lii4qS2FAc6tkzfI
private-key: ${{ secrets.OK_RELEASE_BRIDGE_APP_PRIVATE_KEY }}
owner: inkeep
repositories: agents-private
permission-contents: read
permission-pull-requests: read
- name: Correct the stable stamp by containment
if: steps.derive.outputs.channel == 'stable'
env:
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
CROSS_REPO_TOKEN: ${{ steps.bridge-token.outputs.token }}
# Absent by default, so wiring credentials cannot by itself start
# rewriting tickets. Set to `live` on the repo to arm real writes.
STAMP_MODE: ${{ vars.STAMP_MODE }}
run: node .github/scripts/stamp-by-containment.mjs
# Being decoupled from the release path is what makes a failure here easy
# to miss: the cut it belongs to goes green, ships, and announces itself,
# so nothing about the release looks wrong and only this workflow's own
# run is red. Five consecutive stable cuts failed that way over a day
# before anyone noticed. The guard covers the whole job rather than one
# step, since the useful signal is that stamping did not happen, not which
# line of it stopped first — and the no-key path skips every step above
# without failing, so an unconfigured repo stays silent.
#
# `failure() || cancelled()`, not bare `failure()`: the job carries
# `timeout-minutes`, and a timeout kill CANCELS the job rather than
# failing it, so a run that hangs on the Linear API or on the full-history
# checkout would otherwise be the one path that pages nobody. That also
# picks up an operator-initiated cancel, which is the right call here —
# stamping did not happen either way, and `cancel-in-progress: false`
# means no run is ever preempted by a newer one.
- name: Alert on failed stamping
if: failure() || cancelled()
env:
SLACK_RELEASES_WEBHOOK_URL: ${{ secrets.SLACK_RELEASES_WEBHOOK_URL }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
CHANNEL: ${{ steps.derive.outputs.channel }}
JOB_STATUS: ${{ job.status }}
run: |
set -uo pipefail
RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
TAG="${RELEASE_TAG:-an unidentified tag}"
# A timeout and an operator cancel are indistinguishable to an
# expression — `job.status` exposes no separate timed-out state — so
# the page names both rather than guessing which one happened.
if [[ "${JOB_STATUS:-}" == "cancelled" ]]; then
OUTCOME="was cancelled or timed out"
else
OUTCOME="failed"
fi
TEXT="Linear release stamping ${OUTCOME} for ${TAG}${CHANNEL:+ (${CHANNEL})}. The release itself is unaffected; Linear is now out of sync with what shipped. Re-running is safe for transient failures. After a stamping-code fix reaches main, dispatch linear-release.yml fresh on main with release_tag=${TAG}; re-running the old run keeps its old code. Stable dispatches re-verify every stable release's stamps, not just this one. ${RUN_URL}"
{
echo "## Linear stamping ${OUTCOME}"
echo "Tag: \`${TAG}\`"
echo "Run: ${RUN_URL}"
} >> "$GITHUB_STEP_SUMMARY"
# A lost notification must never be the thing that fails a release
# job, so both the missing-secret and dead-webhook paths warn and
# return success — the red run is still the underlying alert.
WEBHOOK_URL="${SLACK_RELEASES_WEBHOOK_URL:-${SLACK_WEBHOOK_URL:-}}"
if [[ -z "$WEBHOOK_URL" ]]; then
echo "::warning::No Slack webhook secret is configured; the workflow failure remains the alert."
exit 0
fi
payload=$(jq -nc --arg text "$TEXT" '{text: $text}')
if ! curl -sS --fail --connect-timeout 10 --max-time 30 -X POST \
-H 'Content-type: application/json' \
--data "$payload" \
"$WEBHOOK_URL"; then
echo "::warning::Linear stamping failure alert failed to POST."
fi