Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/actions/linear-release-sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Linear Release Sync

A GitHub Action that syncs Linear issues to the "Released" state when a GitHub release is published. It finds all PRs between two releases, extracts Linear issue IDs from PR descriptions and branch names, and moves matching issues from "Ready for Release" to "Released".

## Features

- Fetches all team keys from Linear to filter false positive issue IDs (e.g. `pr-3354`, `snap-1`)
- Extracts Linear issue IDs from PR descriptions and branch names (e.g., `ENG-1234`, `DEVOPS-471`)
- Strict time-based filtering: only includes PRs merged before the release was published
- Moves issues from "Ready for Release" to "Released" state
- Adds release comments with version and date
- For stable releases on already-released issues, adds "Now available in stable release" comments
- Skips CVE issues automatically
- Supports dry-run mode for previewing changes

## Usage

Add to your release workflow:

```yaml
sync_linear:
if: ${{ !contains(needs.publish.outputs.semver_parsed, '-next') }}
needs: [publish]
runs-on: ubuntu-latest
steps:
- name: Sync Linear issues
uses: loft-sh/github-actions/.github/actions/linear-release-sync@linear-release-sync/v1
with:
release-tag: ${{ needs.publish.outputs.release_version }}
repo-name: vcluster
github-token: ${{ secrets.GH_ACCESS_TOKEN }}
linear-token: ${{ secrets.LINEAR_TOKEN }}
```

## Configuration

### Required Inputs

| Input | Description |
|---------------|-------------------------------------------------|
| release-tag | The tag of the new release (e.g. `v1.2.0`) |
| repo-name | The GitHub repository name |
| github-token | GitHub token with read access to the repository |
| linear-token | Linear API token for updating issues |

### Optional Inputs

| Input | Default | Description |
|------------------------------|----------------------|------------------------------------------------------------|
| repo-owner | `loft-sh` | The GitHub owner of the repository |
| previous-tag | *(auto-detected)* | The previous release tag |
| released-state-name | `Released` | The Linear workflow state name for released issues |
| ready-for-release-state-name | `Ready for Release` | The Linear workflow state name for issues ready to release |
| linear-teams | *(all teams)* | Comma-separated list of Linear team names to process |
| linear-projects | *(all projects)* | Comma-separated list of Linear project names to process |
| strict-filtering | `true` | Only include PRs merged before the release was published |
| dry-run | `false` | Preview changes without modifying Linear |
| debug | `false` | Enable debug logging |

## Development

### Testing

Run the included unit tests:

```bash
make test-linear-release-sync
```

### Building locally

```bash
make build-linear-release-sync
```

### Releasing

The action runs a pre-built binary downloaded from a GitHub release at runtime (no Go toolchain needed in consumer workflows). The `release-linear-release-sync.yaml` workflow builds the binary and attaches it to a GitHub release.

**New major/minor version** (e.g. first release, or `v2`):

```bash
git tag linear-release-sync/v1
git push origin linear-release-sync/v1
```

This triggers the workflow automatically via `on: push: tags`.

**Update an existing version** (e.g. rebuild `v1` after a source change):

Force-pushing an existing tag does not trigger `on: push: tags` in GitHub Actions. Use `workflow_dispatch` instead:

```bash
# Via GitHub CLI
gh workflow run release-linear-release-sync.yaml -f tag=linear-release-sync/v1

# Or use the "Run workflow" button in the GitHub Actions UI
```

The workflow builds the binary from the branch it is dispatched against and uploads it to the existing release with `--clobber`.
51 changes: 51 additions & 0 deletions .github/actions/linear-release-sync/REVIEW.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Code Review: linear-release-sync

## Blocking

- [x] **#1 Supply-chain risk: no checksum verification for downloaded binary** (`action.yml:58-62`)
The binary download URL is hardcoded. If someone compromises the release asset, every consumer gets the malicious binary. Consider adding a `.sha256` checksum verification step.

- [x] **#4 `os.Kill` cannot be caught** (`main.go:88`)
`signal.Notify` for `SIGKILL` is a no-op on Linux. Replace with `syscall.SIGTERM`:
```go
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
```

- [x] **#7 Nil-error wrapping in mutations** (`linear.go:316, 337`)
`updateIssueState` and `createComment` return `fmt.Errorf("mutation failed: %w", err)` when either `err != nil` OR `!Success`. If `err == nil` and `Success == false`, the message is `"mutation failed: <nil>"`. Split into two checks:
```go
if err != nil {
return fmt.Errorf("mutation failed: %w", err)
}
if !mutation.IssueUpdate.Success {
return fmt.Errorf("mutation failed: issue update returned success=false")
}
```

## Warn

- [x] **#2 Token in env var** (`action.yml:53`)
Already follows convention: "Secrets via `env:` preferred over `with:`" (CONVENTIONS.md). GHA auto-masks secrets in logs. No change needed.

- [x] **#3 Linear auth header scheme** (`linear.go:41`)
Linear API docs recommend bare `Authorization: <token>` (no `Bearer` prefix). Current code is correct.

- [x] **#5 Logger via context.WithValue is fragile** (`main.go:91-92, linear.go:249`)
Moved logger to a field on `LinearClient`. Removed `LoggerKey` and context injection.

- [x] **#6 Unused `PageSize` constant** (`pr.go:14`)
`PageSize = 100` is defined in `pr.go` but unused there (also defined in `changelog/pull-requests/pr.go`).

- [x] **#9 No tests for changelog packages** (`changelog/pull-requests/`, `changelog/releases/`)
Added tests using httptest mock GraphQL server. Covers pagination, deduplication, unmerged PR filtering, time-based filtering, semver range matching, and edge cases.

## Nit

- [x] **#10 Misleading test file name** (`integration_test.go`)
Renamed to `flow_test.go`.

- [x] **#12 Inconsistent Go cache setting** (`release-linear-release-sync.yaml:28` vs `test-linear-release-sync.yaml:24`)
Removed `cache: false` from release workflow — no reason to disable it.

- [x] **#13 Old Go version** (`go.mod`)
Bumped from 1.22.5 to 1.26.
139 changes: 139 additions & 0 deletions .github/actions/linear-release-sync/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
name: 'Linear Release Sync'
description: 'Syncs Linear issues to Released state when a GitHub release is published. Finds PRs between releases, extracts Linear issue IDs, and moves matching issues from Ready for Release to Released.'

inputs:
release-tag:
description: 'The tag of the new release (e.g. v1.2.0)'
required: true
repo-owner:
description: 'The GitHub owner of the repository'
required: false
default: 'loft-sh'
repo-name:
description: 'The GitHub repository name'
required: true
github-token:
description: 'GitHub token with read access to the repository'
required: true
linear-token:
description: 'Linear API token for updating issues'
required: true
previous-tag:
description: 'The previous release tag (auto-detected if not set)'
required: false
default: ''
released-state-name:
description: 'The Linear workflow state name for released issues'
required: false
default: 'Released'
ready-for-release-state-name:
description: 'The Linear workflow state name for issues ready to release'
required: false
default: 'Ready for Release'
strict-filtering:
description: 'Only include PRs merged before the release was published (recommended)'
required: false
default: 'true'
linear-teams:
description: 'Comma-separated list of Linear team names to process (optional, default: all)'
required: false
default: ''
linear-projects:
description: 'Comma-separated list of Linear project names to process (optional, default: all)'
required: false
default: ''
dry-run:
description: 'Preview changes without modifying Linear'
required: false
default: 'false'
debug:
description: 'Enable debug logging'
required: false
default: 'false'

runs:
using: 'composite'
steps:
- name: Download linear-release-sync binary
id: download
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
run: |
BINARY_DIR="$(mktemp -d)"
echo "binary_dir=$BINARY_DIR" >> "$GITHUB_OUTPUT"

RELEASE_URL="https://github.com/loft-sh/github-actions/releases/download/linear-release-sync%2Fv1"

for asset in linear-release-sync-linux-amd64 linear-release-sync-linux-amd64.sha256; do
curl -fsSL \
-H "Authorization: token ${GH_TOKEN}" \
-H "Accept: application/octet-stream" \
-o "$BINARY_DIR/$asset" \
"$RELEASE_URL/$asset"
done

# Verify SHA-256 checksum
(cd "$BINARY_DIR" && sha256sum -c linear-release-sync-linux-amd64.sha256)

chmod +x "$BINARY_DIR/linear-release-sync-linux-amd64"

# Verify the binary is a valid ELF executable, not an HTML error page
if ! file "$BINARY_DIR/linear-release-sync-linux-amd64" | grep -q "ELF"; then
echo "::error::Downloaded file is not a valid ELF binary"
file "$BINARY_DIR/linear-release-sync-linux-amd64"
exit 1
fi

- name: Run Linear Release Sync
shell: bash
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
LINEAR_TOKEN: ${{ inputs.linear-token }}
INPUT_RELEASE_TAG: ${{ inputs.release-tag }}
INPUT_REPO_OWNER: ${{ inputs.repo-owner }}
INPUT_REPO_NAME: ${{ inputs.repo-name }}
INPUT_PREVIOUS_TAG: ${{ inputs.previous-tag }}
INPUT_RELEASED_STATE: ${{ inputs.released-state-name }}
INPUT_READY_STATE: ${{ inputs.ready-for-release-state-name }}
INPUT_STRICT_FILTERING: ${{ inputs.strict-filtering }}
INPUT_DRY_RUN: ${{ inputs.dry-run }}
INPUT_DEBUG: ${{ inputs.debug }}
INPUT_LINEAR_TEAMS: ${{ inputs.linear-teams }}
INPUT_LINEAR_PROJECTS: ${{ inputs.linear-projects }}
BINARY_DIR: ${{ steps.download.outputs.binary_dir }}
run: |
ARGS=(
-release-tag="$INPUT_RELEASE_TAG"
-owner="$INPUT_REPO_OWNER"
-repo="$INPUT_REPO_NAME"
-released-state-name="$INPUT_RELEASED_STATE"
-ready-for-release-state-name="$INPUT_READY_STATE"
-strict-filtering="$INPUT_STRICT_FILTERING"
)

if [ -n "$INPUT_PREVIOUS_TAG" ]; then
ARGS+=(-previous-tag="$INPUT_PREVIOUS_TAG")
fi

if [ -n "$INPUT_LINEAR_TEAMS" ]; then
ARGS+=(-linear-teams="$INPUT_LINEAR_TEAMS")
fi

if [ -n "$INPUT_LINEAR_PROJECTS" ]; then
ARGS+=(-linear-projects="$INPUT_LINEAR_PROJECTS")
fi

if [ "$INPUT_DRY_RUN" = "true" ]; then
ARGS+=(-dry-run)
fi

if [ "$INPUT_DEBUG" = "true" ]; then
ARGS+=(-debug)
fi

"$BINARY_DIR/linear-release-sync-linux-amd64" "${ARGS[@]}"

branding:
icon: 'check-circle'
color: 'purple'
Loading
Loading