desktop-release #725
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |